From bd7984f8e3535444588282c10e19d69058f6465d Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 5 Jun 2026 02:23:32 +0300 Subject: [PATCH] Add complete ScoutIQ system: Crawler (RSS+AI), CRUD Controllers (Organizations, Contacts, Opportunities, Sources), dynamic Views, API routes, CLI collector --- app/Controllers/Admin/ContactsController.php | 163 ++++++++++++ .../Admin/OpportunitiesController.php | 112 ++++++++ .../Admin/OrganizationsController.php | 219 +++++++++++++++ app/Controllers/Admin/SourcesController.php | 93 +++++++ app/Services/Crawler/AiAnalyzer.php | 245 +++++++++++++++++ app/Services/Crawler/Collector.php | 249 ++++++++++++++++++ app/Services/Crawler/RssParser.php | 105 ++++++++ bootstrap/app.php | 8 + cli.php | 35 +++ public/index.php | 46 ++++ resources/views/admin/contacts/form.php | 59 +++++ resources/views/admin/contacts/index.php | 67 +++++ resources/views/admin/contacts/show.php | 82 ++++++ resources/views/admin/opportunities/index.php | 101 +++++++ resources/views/admin/opportunities/show.php | 88 +++++++ resources/views/admin/organizations/form.php | 83 ++++++ resources/views/admin/organizations/index.php | 115 ++++++++ resources/views/admin/organizations/show.php | 108 ++++++++ resources/views/admin/sources/form.php | 46 ++++ resources/views/admin/sources/index.php | 60 +++++ 20 files changed, 2084 insertions(+) create mode 100644 app/Controllers/Admin/ContactsController.php create mode 100644 app/Controllers/Admin/OpportunitiesController.php create mode 100644 app/Controllers/Admin/OrganizationsController.php create mode 100644 app/Controllers/Admin/SourcesController.php create mode 100644 app/Services/Crawler/AiAnalyzer.php create mode 100644 app/Services/Crawler/Collector.php create mode 100644 app/Services/Crawler/RssParser.php create mode 100644 resources/views/admin/contacts/form.php create mode 100644 resources/views/admin/contacts/index.php create mode 100644 resources/views/admin/contacts/show.php create mode 100644 resources/views/admin/opportunities/index.php create mode 100644 resources/views/admin/opportunities/show.php create mode 100644 resources/views/admin/organizations/form.php create mode 100644 resources/views/admin/organizations/index.php create mode 100644 resources/views/admin/organizations/show.php create mode 100644 resources/views/admin/sources/form.php create mode 100644 resources/views/admin/sources/index.php diff --git a/app/Controllers/Admin/ContactsController.php b/app/Controllers/Admin/ContactsController.php new file mode 100644 index 0000000..6b4da23 --- /dev/null +++ b/app/Controllers/Admin/ContactsController.php @@ -0,0 +1,163 @@ +pdo = $connection->getPdo(); + } + + public function index(Request $request, Response $response): string + { + $search = $request->get('search', ''); + $page = max(1, (int)$request->get('page', 1)); + $perPage = 20; + $offset = ($page - 1) * $perPage; + + $where = ['c.deleted_at IS NULL']; + $params = []; + + if ($search) { + $where[] = '(c.name LIKE ? OR c.email LIKE ? OR c.phone LIKE ?)'; + $params[] = "%{$search}%"; + $params[] = "%{$search}%"; + $params[] = "%{$search}%"; + } + + $whereClause = implode(' AND ', $where); + + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM contacts c WHERE {$whereClause}"); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + $stmt = $this->pdo->prepare( + "SELECT c.*, org.name as org_name, + (SELECT COUNT(*) FROM interactions WHERE contact_id = c.id) as interaction_count + FROM contacts c + LEFT JOIN organizations org ON org.id = c.organization_id + WHERE {$whereClause} + ORDER BY c.updated_at DESC + LIMIT ? OFFSET ?" + ); + $stmt->execute(array_merge($params, [$perPage, $offset])); + $contacts = $stmt->fetchAll(); + + return $this->render('admin/contacts/index', [ + 'contacts' => $contacts, + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + 'search' => $search, + ], 'admin'); + } + + public function show(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare( + "SELECT c.*, org.name as org_name, org.id as org_id + FROM contacts c + LEFT JOIN organizations org ON org.id = c.organization_id + WHERE c.id = ? AND c.deleted_at IS NULL" + ); + $stmt->execute([$id]); + $contact = $stmt->fetch(); + + if (!$contact) { $response->redirect('/admin/contacts'); return ''; } + + $stmt = $this->pdo->prepare("SELECT * FROM interactions WHERE contact_id = ? ORDER BY created_at DESC"); + $stmt->execute([$id]); + $interactions = $stmt->fetchAll(); + + return $this->render('admin/contacts/show', [ + 'contact' => $contact, + 'interactions' => $interactions, + ], 'admin'); + } + + public function create(Request $request, Response $response): string + { + $orgId = $request->get('organization_id', ''); + $orgs = $this->pdo->query("SELECT id, name FROM organizations WHERE deleted_at IS NULL ORDER BY name")->fetchAll(); + return $this->render('admin/contacts/form', [ + 'contact' => null, + 'organizations' => $orgs, + 'selectedOrgId' => $orgId, + ], 'admin'); + } + + public function edit(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare("SELECT * FROM contacts WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$id]); + $contact = $stmt->fetch(); + if (!$contact) { $response->redirect('/admin/contacts'); return ''; } + + $orgs = $this->pdo->query("SELECT id, name FROM organizations WHERE deleted_at IS NULL ORDER BY name")->fetchAll(); + return $this->render('admin/contacts/form', [ + 'contact' => $contact, + 'organizations' => $orgs, + 'selectedOrgId' => $contact['organization_id'], + ], 'admin'); + } + + public function store(Request $request, Response $response): void + { + $id = $request->post('id', ''); + $name = $request->post('name', ''); + $email = $request->post('email', ''); + $phone = $request->post('phone', ''); + $position = $request->post('position', ''); + $organizationId = $request->post('organization_id', ''); + $notes = $request->post('notes', ''); + + try { + if ($id) { + $stmt = $this->pdo->prepare("UPDATE contacts SET name=?, email=?, phone=?, position=?, organization_id=?, notes=? WHERE id=?"); + $stmt->execute([$name, $email ?: null, $phone ?: null, $position ?: null, $organizationId ?: null, $notes, $id]); + } else { + $stmt = $this->pdo->prepare("INSERT INTO contacts (name, email, phone, position, organization_id, notes) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->execute([$name, $email ?: null, $phone ?: null, $position ?: null, $organizationId ?: null, $notes]); + $id = $this->pdo->lastInsertId(); + } + $this->session->setFlash('success', 'Contact saved.'); + $response->redirect('/admin/contacts/' . $id); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Error: ' . $e->getMessage()); + $response->redirect('/admin/contacts'); + } + } + + public function delete(Request $request, Response $response, int $id): void + { + $this->pdo->prepare("UPDATE contacts SET deleted_at = NOW() WHERE id = ?")->execute([$id]); + $this->session->setFlash('success', 'Contact deleted.'); + $response->redirect('/admin/contacts'); + } + + public function addInteraction(Request $request, Response $response, int $contactId): void + { + $type = $request->post('type', 'note'); + $notes = $request->post('notes', ''); + + try { + $stmt = $this->pdo->prepare("INSERT INTO interactions (contact_id, type, notes) VALUES (?, ?, ?)"); + $stmt->execute([$contactId, $type, $notes]); + $this->session->setFlash('success', 'Interaction logged.'); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Error: ' . $e->getMessage()); + } + $response->redirect('/admin/contacts/' . $contactId); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/OpportunitiesController.php b/app/Controllers/Admin/OpportunitiesController.php new file mode 100644 index 0000000..e2b503d --- /dev/null +++ b/app/Controllers/Admin/OpportunitiesController.php @@ -0,0 +1,112 @@ +pdo = $connection->getPdo(); + } + + public function index(Request $request, Response $response): string + { + $type = $request->get('type', ''); + $search = $request->get('search', ''); + $status = $request->get('status', ''); + $page = max(1, (int)$request->get('page', 1)); + $perPage = 20; + $offset = ($page - 1) * $perPage; + + $where = ['o.deleted_at IS NULL']; + $params = []; + + if ($type) { $where[] = 'o.type = ?'; $params[] = $type; } + if ($status) { $where[] = 'o.status = ?'; $params[] = $status; } + if ($search) { + $where[] = '(o.title LIKE ? OR o.description LIKE ?)'; + $params[] = "%{$search}%"; + $params[] = "%{$search}%"; + } + + $whereClause = implode(' AND ', $where); + + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM opportunities o WHERE {$whereClause}"); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + $stmt = $this->pdo->prepare( + "SELECT o.*, org.name as org_name, + GROUP_CONCAT(DISTINCT t.name) as tag_names + FROM opportunities o + LEFT JOIN organizations org ON org.id = o.organization_id + LEFT JOIN opportunity_tags ot ON ot.opportunity_id = o.id + LEFT JOIN tags t ON t.id = ot.tag_id + WHERE {$whereClause} + GROUP BY o.id + ORDER BY o.score DESC, o.created_at DESC + LIMIT ? OFFSET ?" + ); + $stmt->execute(array_merge($params, [$perPage, $offset])); + $opportunities = $stmt->fetchAll(); + + // Get type counts for sidebar + $typeCounts = $this->pdo->query( + "SELECT type, COUNT(*) as count FROM opportunities WHERE deleted_at IS NULL GROUP BY type" + )->fetchAll(PDO::FETCH_KEY_PAIR); + + return $this->render('admin/opportunities/index', [ + 'opportunities' => $opportunities, + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + 'type' => $type, + 'status' => $status, + 'search' => $search, + 'typeCounts' => $typeCounts, + 'types' => ['grant', 'competition', 'demo_day', 'event', 'partnership', 'investment', 'other'], + 'statuses' => ['active', 'closed', 'expired'], + ], 'admin'); + } + + public function show(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare( + "SELECT o.*, org.name as org_name, org.type as org_type, org.website_url as org_website, + GROUP_CONCAT(DISTINCT t.name) as tag_names + FROM opportunities o + LEFT JOIN organizations org ON org.id = o.organization_id + LEFT JOIN opportunity_tags ot ON ot.opportunity_id = o.id + LEFT JOIN tags t ON t.id = ot.tag_id + WHERE o.id = ? AND o.deleted_at IS NULL + GROUP BY o.id" + ); + $stmt->execute([$id]); + $opportunity = $stmt->fetch(); + + if (!$opportunity) { + $response->redirect('/admin/opportunities'); + return ''; + } + + // Get applications + $stmt = $this->pdo->prepare("SELECT * FROM applications WHERE opportunity_id = ? AND deleted_at IS NULL ORDER BY created_at DESC"); + $stmt->execute([$id]); + $applications = $stmt->fetchAll(); + + return $this->render('admin/opportunities/show', [ + 'opportunity' => $opportunity, + 'applications' => $applications, + ], 'admin'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/OrganizationsController.php b/app/Controllers/Admin/OrganizationsController.php new file mode 100644 index 0000000..b192c8b --- /dev/null +++ b/app/Controllers/Admin/OrganizationsController.php @@ -0,0 +1,219 @@ +pdo = $connection->getPdo(); + } + + /** + * List all organizations with filters. + */ + public function index(Request $request, Response $response): string + { + $type = $request->get('type', ''); + $search = $request->get('search', ''); + $page = max(1, (int)$request->get('page', 1)); + $perPage = 20; + $offset = ($page - 1) * $perPage; + + $where = ['o.deleted_at IS NULL']; + $params = []; + + if ($type) { + $where[] = 'o.type = ?'; + $params[] = $type; + } + if ($search) { + $where[] = '(o.name LIKE ? OR o.domain LIKE ? OR o.description LIKE ?)'; + $params[] = "%{$search}%"; + $params[] = "%{$search}%"; + $params[] = "%{$search}%"; + } + + $whereClause = implode(' AND ', $where); + + // Count total + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM organizations o WHERE {$whereClause}"); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + // Fetch page + $stmt = $this->pdo->prepare( + "SELECT o.*, + (SELECT COUNT(*) FROM opportunities WHERE organization_id = o.id AND deleted_at IS NULL) as opportunities_count + FROM organizations o + WHERE {$whereClause} + ORDER BY o.updated_at DESC + LIMIT ? OFFSET ?" + ); + $stmt->execute(array_merge($params, [$perPage, $offset])); + $organizations = $stmt->fetchAll(); + + return $this->render('admin/organizations/index', [ + 'organizations' => $organizations, + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + 'type' => $type, + 'search' => $search, + 'types' => ['vc', 'angel', 'accelerator', 'incubator', 'venture_studio', 'partner'], + 'statuses' => ['New', 'Researching', 'Contacted', 'Follow Up', 'Meeting Scheduled', 'Interested', 'Rejected', 'Invested'], + ], 'admin'); + } + + /** + * Show single organization. + */ + public function show(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare("SELECT * FROM organizations WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$id]); + $org = $stmt->fetch(); + + if (!$org) { + $response->redirect('/admin/organizations'); + return ''; + } + + // Get opportunities for this org + $stmt = $this->pdo->prepare( + "SELECT o.*, GROUP_CONCAT(t.name) as tag_names + FROM opportunities o + LEFT JOIN opportunity_tags ot ON ot.opportunity_id = o.id + LEFT JOIN tags t ON t.id = ot.tag_id + WHERE o.organization_id = ? AND o.deleted_at IS NULL + GROUP BY o.id + ORDER BY o.score DESC" + ); + $stmt->execute([$id]); + $opportunities = $stmt->fetchAll(); + + // Get contacts for this org + $stmt = $this->pdo->prepare( + "SELECT c.*, + (SELECT COUNT(*) FROM interactions WHERE contact_id = c.id) as interaction_count + FROM contacts c + WHERE c.organization_id = ? AND c.deleted_at IS NULL + ORDER BY c.created_at DESC" + ); + $stmt->execute([$id]); + $contacts = $stmt->fetchAll(); + + // Get activity logs + $stmt = $this->pdo->prepare( + "SELECT * FROM activity_logs WHERE description LIKE ? ORDER BY created_at DESC LIMIT 20" + ); + $stmt->execute(['%' . $org['name'] . '%']); + $activities = $stmt->fetchAll(); + + return $this->render('admin/organizations/show', [ + 'org' => $org, + 'opportunities' => $opportunities, + 'contacts' => $contacts, + 'activities' => $activities, + ], 'admin'); + } + + /** + * Show create/edit form. + */ + public function create(Request $request, Response $response): string + { + return $this->render('admin/organizations/form', [ + 'org' => null, + 'types' => ['vc', 'angel', 'accelerator', 'incubator', 'venture_studio', 'partner'], + 'statuses' => ['New', 'Researching', 'Contacted', 'Follow Up', 'Meeting Scheduled', 'Interested', 'Rejected', 'Invested'], + ], 'admin'); + } + + /** + * Show edit form. + */ + public function edit(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare("SELECT * FROM organizations WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$id]); + $org = $stmt->fetch(); + + if (!$org) { + $response->redirect('/admin/organizations'); + return ''; + } + + return $this->render('admin/organizations/form', [ + 'org' => $org, + 'types' => ['vc', 'angel', 'accelerator', 'incubator', 'venture_studio', 'partner'], + 'statuses' => ['New', 'Researching', 'Contacted', 'Follow Up', 'Meeting Scheduled', 'Interested', 'Rejected', 'Invested'], + ], 'admin'); + } + + /** + * Save organization (create or update). + */ + public function store(Request $request, Response $response): void + { + $id = $request->post('id', ''); + $name = $request->post('name', ''); + $domain = $request->post('domain', ''); + $type = $request->post('type', 'partner'); + $country = $request->post('country', ''); + $city = $request->post('city', ''); + $websiteUrl = $request->post('website_url', ''); + $description = $request->post('description', ''); + $crmStatus = $request->post('crm_status', 'New'); + $fundingStage = $request->post('funding_stage', ''); + + try { + if ($id) { + $stmt = $this->pdo->prepare( + "UPDATE organizations SET name=?, domain=?, type=?, country=?, city=?, website_url=?, + description=?, crm_status=?, funding_stage=? WHERE id=?" + ); + $stmt->execute([$name, $domain ?: null, $type, $country ?: null, $city ?: null, $websiteUrl ?: null, $description, $crmStatus, $fundingStage ?: null, $id]); + $this->session->setFlash('success', 'Organization updated successfully.'); + } else { + $stmt = $this->pdo->prepare( + "INSERT INTO organizations (name, domain, type, country, city, website_url, description, crm_status, funding_stage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([$name, $domain ?: null, $type, $country ?: null, $city ?: null, $websiteUrl ?: null, $description, $crmStatus, $fundingStage ?: null]); + $id = $this->pdo->lastInsertId(); + $this->session->setFlash('success', 'Organization created successfully.'); + } + + $response->redirect('/admin/organizations/' . $id); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Error saving organization: ' . $e->getMessage()); + $response->redirect('/admin/organizations' . ($id ? '/' . $id : '/create')); + } + } + + /** + * Delete organization (soft delete). + */ + public function delete(Request $request, Response $response, int $id): void + { + try { + $stmt = $this->pdo->prepare("UPDATE organizations SET deleted_at = NOW() WHERE id = ?"); + $stmt->execute([$id]); + $this->session->setFlash('success', 'Organization deleted.'); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Error deleting organization.'); + } + $response->redirect('/admin/organizations'); + } +} \ No newline at end of file diff --git a/app/Controllers/Admin/SourcesController.php b/app/Controllers/Admin/SourcesController.php new file mode 100644 index 0000000..373fa8b --- /dev/null +++ b/app/Controllers/Admin/SourcesController.php @@ -0,0 +1,93 @@ +pdo = $connection->getPdo(); + $this->collector = $collector; + } + + public function index(Request $request, Response $response): string + { + $sources = $this->collector->getActiveSources(); + // Also get inactive ones + $stmt = $this->pdo->query("SELECT * FROM sources ORDER BY status, name"); + $allSources = $stmt->fetchAll(); + return $this->render('admin/sources/index', ['sources' => $allSources], 'admin'); + } + + public function create(Request $request, Response $response): string + { + return $this->render('admin/sources/form', ['source' => null], 'admin'); + } + + public function edit(Request $request, Response $response, int $id): string + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = ?"); + $stmt->execute([$id]); + $source = $stmt->fetch(); + if (!$source) { $response->redirect('/admin/sources'); return ''; } + return $this->render('admin/sources/form', ['source' => $source], 'admin'); + } + + public function store(Request $request, Response $response): void + { + $id = $request->post('id', ''); + $name = $request->post('name', ''); + $url = $request->post('url', ''); + $type = $request->post('type', 'rss'); + $status = $request->post('status', 'active'); + + try { + if ($id) { + $stmt = $this->pdo->prepare("UPDATE sources SET name=?, url=?, type=?, status=? WHERE id=?"); + $stmt->execute([$name, $url, $type, $status, $id]); + } else { + $stmt = $this->pdo->prepare("INSERT INTO sources (name, url, type, status) VALUES (?, ?, ?, ?)"); + $stmt->execute([$name, $url, $type, $status]); + } + $this->session->setFlash('success', 'Source saved.'); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Error: ' . $e->getMessage()); + } + $response->redirect('/admin/sources'); + } + + public function delete(Request $request, Response $response, int $id): void + { + $this->pdo->prepare("DELETE FROM sources WHERE id = ?")->execute([$id]); + $this->session->setFlash('success', 'Source deleted.'); + $response->redirect('/admin/sources'); + } + + public function run(Request $request, Response $response, int $id): void + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = ?"); + $stmt->execute([$id]); + $source = $stmt->fetch(); + if (!$source) { $this->session->setFlash('error', 'Source not found.'); $response->redirect('/admin/sources'); return; } + + try { + $result = $this->collector->collectSource($source); + $this->session->setFlash('success', "Collected {$result['entries_found']} entries, {$result['opportunities']} new opportunities."); + } catch (Throwable $e) { + $this->session->setFlash('error', 'Collection error: ' . $e->getMessage()); + } + $response->redirect('/admin/sources'); + } +} \ No newline at end of file diff --git a/app/Services/Crawler/AiAnalyzer.php b/app/Services/Crawler/AiAnalyzer.php new file mode 100644 index 0000000..730a74c --- /dev/null +++ b/app/Services/Crawler/AiAnalyzer.php @@ -0,0 +1,245 @@ +apiKey = $config['gemini']['api_key'] ?? null; + $this->model = $config['gemini']['model'] ?? 'gemini-1.5-flash-latest'; + } + + /** + * Analyze text using Google Gemini AI to classify and extract info. + * Returns: type, score, tags, is_opportunity, summary + */ + public function analyze(string $title, string $description): array + { + if (!$this->apiKey) { + return $this->fallbackAnalysis($title, $description); + } + + $prompt = <<callGemini($prompt); + $json = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE && isset($json['type'])) { + return $json; + } + } catch (Throwable $e) { + // Fallback + } + + return $this->fallbackAnalysis($title, $description); + } + + /** + * Analyze content for organization/investor extraction. + */ + public function extractOrganization(string $text): array + { + if (!$this->apiKey) { + return [ + 'name' => null, + 'type' => null, + 'country' => null, + 'website' => null, + 'description' => substr($text, 0, 500), + ]; + } + + $prompt = <<callGemini($prompt); + $json = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $json; + } + } catch (Throwable $e) { + // Fallback + } + + return [ + 'name' => null, + 'type' => null, + 'country' => null, + 'website' => null, + 'description' => substr($text, 0, 500), + ]; + } + + /** + * Call Gemini API. + */ + private function callGemini(string $prompt): string + { + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}"; + + $payload = json_encode([ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt] + ] + ] + ], + 'generationConfig' => [ + 'temperature' => 0.2, + 'maxOutputTokens' => 500, + ] + ]); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => 30, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $response = @file_get_contents($url, false, $context); + if (!$response) { + return '{}'; + } + + $data = json_decode($response, true); + return $data['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; + } + + /** + * Simple keyword-based fallback when AI is unavailable. + */ + private function fallbackAnalysis(string $title, string $description): array + { + $text = strtolower($title . ' ' . $description); + + $type = 'news'; + $opportunityType = 'other'; + $score = 10; + $tags = []; + $isOpportunity = false; + + // Keyword patterns + if (preg_match('/\b(grant|funding|award|prize)\b/i', $text)) { + $type = 'grant'; + $opportunityType = 'grant'; + $score = 75; + $isOpportunity = true; + $tags[] = 'grant'; + } + if (preg_match('/\b(competition|contest|challenge|hackathon)\b/i', $text)) { + $type = 'competition'; + $opportunityType = 'competition'; + $score = 65; + $isOpportunity = true; + $tags[] = 'competition'; + } + if (preg_match('/\b(demo day|pitch day|investor day)\b/i', $text)) { + $type = 'demo_day'; + $opportunityType = 'demo_day'; + $score = 60; + $isOpportunity = true; + $tags[] = 'demo_day'; + } + if (preg_match('/\b(accelerator|incubator|venture studio)\b/i', $text)) { + $opportunityType = 'accelerator'; + $score = 80; + $isOpportunity = true; + $tags[] = 'accelerator'; + $type = 'investment'; + } + if (preg_match('/\b(vc|venture capital|seed fund|series [a-z])\b/i', $text)) { + $opportunityType = 'vc_funding'; + $score = 85; + $isOpportunity = true; + $tags[] = 'vc_funding'; + $type = 'investment'; + } + if (preg_match('/\b(partnership|collaboration|strategic alliance)\b/i', $text)) { + $type = 'partnership'; + $opportunityType = 'partnership'; + $score = 50; + $isOpportunity = true; + $tags[] = 'partnership'; + } + if (preg_match('/\b(conference|summit|meetup|webinar|workshop)\b/i', $text)) { + $type = 'event'; + $opportunityType = 'event'; + $score = 40; + $tags[] = 'event'; + } + + // Industry tags + if (preg_match('/\b(ai|artificial intelligence|machine learning|deep learning)\b/i', $text)) { + $tags[] = 'ai'; + } + if (preg_match('/\b(fintech|financial technology|blockchain|crypto)\b/i', $text)) { + $tags[] = 'fintech'; + } + if (preg_match('/\b(saas|software|cloud)\b/i', $text)) { + $tags[] = 'saas'; + } + if (preg_match('/\b(mobility|transportation|ev|electric vehicle|logistics)\b/i', $text)) { + $tags[] = 'mobility'; + } + if (preg_match('/\b(healthtech|healthcare|biotech|medtech)\b/i', $text)) { + $tags[] = 'healthtech'; + } + if (preg_match('/\b(climate|cleantech|sustainability|green energy|renewable)\b/i', $text)) { + $tags[] = 'cleantech'; + } + + $tags = array_unique($tags); + + return [ + 'type' => $type, + 'opportunity_type' => $opportunityType, + 'score' => $score, + 'tags' => $tags, + 'is_opportunity' => $isOpportunity, + 'summary' => substr($description, 0, 200), + 'organization_name' => null, + 'country' => null, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Crawler/Collector.php b/app/Services/Crawler/Collector.php new file mode 100644 index 0000000..a5e64f7 --- /dev/null +++ b/app/Services/Crawler/Collector.php @@ -0,0 +1,249 @@ +pdo = $connection->getPdo(); + $this->rssParser = $rssParser; + $this->aiAnalyzer = $aiAnalyzer; + $this->logger = $logger; + } + + /** + * Collect from all active sources. + */ + public function collectAll(): array + { + $results = [ + 'total_sources' => 0, + 'processed' => 0, + 'errors' => 0, + 'new_opportunities' => 0, + 'new_organizations' => 0, + 'details' => [], + ]; + + $sources = $this->getActiveSources(); + + foreach ($sources as $source) { + $results['total_sources']++; + try { + $result = $this->collectSource($source); + $results['processed']++; + $results['new_opportunities'] += $result['opportunities']; + $results['new_organizations'] += $result['organizations']; + $results['details'][] = [ + 'source' => $source['name'], + 'type' => $source['type'], + 'status' => 'success', + 'entries_found' => $result['entries_found'], + 'new_opportunities' => $result['opportunities'], + 'new_organizations' => $result['organizations'], + ]; + } catch (Throwable $e) { + $results['errors']++; + $results['details'][] = [ + 'source' => $source['name'], + 'type' => $source['type'], + 'status' => 'error', + 'error' => $e->getMessage(), + ]; + } + } + + $this->logger->log(null, 'collector_run', 'Collector completed: ' . json_encode([ + 'total_sources' => $results['total_sources'], + 'processed' => $results['processed'], + 'errors' => $results['errors'], + 'new_opportunities' => $results['new_opportunities'], + 'new_organizations' => $results['new_organizations'], + ])); + + return $results; + } + + /** + * Collect from a single source. + */ + public function collectSource(array $source): array + { + $result = [ + 'entries_found' => 0, + 'opportunities' => 0, + 'organizations' => 0, + ]; + + if ($source['type'] === 'rss') { + $entries = $this->rssParser->fetchEntries($source['url']); + $result['entries_found'] = count($entries); + + foreach ($entries as $entry) { + $this->processEntry($entry, $source, $result); + } + } + + return $result; + } + + /** + * Process a single entry: analyze, save opportunity, save organization. + */ + private function processEntry(array $entry, array $source, array &$result): void + { + // Skip if already exists + if ($this->rssParser->entryExists($entry['url'])) { + return; + } + + // AI Analysis + $analysis = $this->aiAnalyzer->analyze($entry['title'], $entry['description']); + + // Extract organization if any + $orgId = null; + if (!empty($analysis['organization_name'])) { + $orgId = $this->rssParser->organizationExists($analysis['organization_name']); + } + + // If no org found and AI suggests one, try to extract more details + if (!$orgId && !empty($analysis['organization_name'])) { + $orgData = $this->aiAnalyzer->extractOrganization($entry['title'] . ' ' . $entry['description']); + if (!empty($orgData['name'])) { + $orgId = $this->createOrganization($orgData); + if ($orgId) { + $result['organizations']++; + } + } + } + + // Create opportunity + $this->createOpportunity($entry, $analysis, $orgId, $source); + $result['opportunities']++; + } + + /** + * Create an organization record. + */ + private function createOrganization(array $data): ?int + { + try { + $stmt = $this->pdo->prepare( + "INSERT INTO organizations (name, description, type, country, website_url, crm_status) + VALUES (?, ?, ?, ?, ?, 'New')" + ); + $stmt->execute([ + $data['name'], + $data['description'] ?? '', + $data['type'] ?? 'partner', + $data['country'] ?? null, + $data['website'] ?? null, + ]); + return (int)$this->pdo->lastInsertId(); + } catch (Throwable $e) { + return null; + } + } + + /** + * Create an opportunity record. + */ + private function createOpportunity(array $entry, array $analysis, ?int $orgId, array $source): void + { + try { + $score = min(100, max(0, $analysis['score'] ?? 10)); + + $stmt = $this->pdo->prepare( + "INSERT INTO opportunities (title, description, type, organization_id, url, status, score, raw_data) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?)" + ); + $stmt->execute([ + $entry['title'], + $analysis['summary'] ?? $entry['description'], + $analysis['opportunity_type'] ?? $analysis['type'] ?? 'other', + $orgId, + $entry['url'], + $score, + json_encode([ + 'source_id' => $source['id'] ?? null, + 'source_name' => $source['name'] ?? '', + 'published_at' => $entry['published_at'], + 'categories' => $entry['categories'] ?? [], + 'analysis' => $analysis, + ]), + ]); + + $opportunityId = (int)$this->pdo->lastInsertId(); + + // Save tags + if (!empty($analysis['tags'])) { + foreach ($analysis['tags'] as $tagName) { + $tagId = $this->getOrCreateTag($tagName); + if ($tagId) { + $stmt = $this->pdo->prepare( + "INSERT IGNORE INTO opportunity_tags (opportunity_id, tag_id) VALUES (?, ?)" + ); + $stmt->execute([$opportunityId, $tagId]); + } + } + } + + } catch (Throwable $e) { + // Log but don't fail + } + } + + /** + * Get or create a tag. + */ + private function getOrCreateTag(string $name): ?int + { + $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $name)); + $slug = trim($slug, '-'); + + $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE slug = ?"); + $stmt->execute([$slug]); + $id = $stmt->fetchColumn(); + if ($id) { + return (int)$id; + } + + try { + $stmt = $this->pdo->prepare("INSERT INTO tags (name, slug) VALUES (?, ?)"); + $stmt->execute([$name, $slug]); + return (int)$this->pdo->lastInsertId(); + } catch (Throwable $e) { + return null; + } + } + + /** + * Get all active sources. + */ + public function getActiveSources(): array + { + $stmt = $this->pdo->query( + "SELECT s.*, GROUP_CONCAT(sc.category) as categories + FROM sources s + LEFT JOIN source_categories sc ON sc.source_id = s.id + WHERE s.status = 'active' + GROUP BY s.id" + ); + return $stmt->fetchAll() ?: []; + } +} \ No newline at end of file diff --git a/app/Services/Crawler/RssParser.php b/app/Services/Crawler/RssParser.php new file mode 100644 index 0000000..060c86d --- /dev/null +++ b/app/Services/Crawler/RssParser.php @@ -0,0 +1,105 @@ +pdo = $connection->getPdo(); + } + + /** + * Parse an RSS feed URL and return entries. + */ + public function fetchEntries(string $url): array + { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 15, + 'user_agent' => 'ScoutIQ/1.0 (Crawler)', + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $xml = @file_get_contents($url, false, $context); + if (!$xml) { + return []; + } + + $feed = @simplexml_load_string($xml); + if (!$feed) { + return []; + } + + $entries = []; + $items = $feed->channel->item ?? $feed->entry ?? []; + + foreach ($items as $item) { + $title = (string)($item->title ?? ''); + $description = (string)($item->description ?? $item->summary ?? ''); + $link = (string)($item->link ?? $item->guid ?? ''); + $pubDate = (string)($item->pubDate ?? $item->updated ?? ''); + $categories = []; + + if (isset($item->category)) { + foreach ($item->category as $cat) { + $categories[] = (string)$cat; + } + } + + if (empty($title)) { + continue; + } + + $entries[] = [ + 'title' => $title, + 'description' => strip_tags($description), + 'url' => $link, + 'published_at' => $pubDate ? date('Y-m-d H:i:s', strtotime($pubDate)) : date('Y-m-d H:i:s'), + 'categories' => $categories, + 'source_raw' => $xml, + ]; + } + + return $entries; + } + + /** + * Check if entry URL already exists in opportunities. + */ + public function entryExists(string $url): bool + { + $stmt = $this->pdo->prepare("SELECT id FROM opportunities WHERE url = ? AND deleted_at IS NULL"); + $stmt->execute([$url]); + return (bool)$stmt->fetch(); + } + + /** + * Check if organization already exists by domain or name. + */ + public function organizationExists(string $name, ?string $domain = null): ?int + { + if ($domain) { + $stmt = $this->pdo->prepare("SELECT id FROM organizations WHERE domain = ? AND deleted_at IS NULL"); + $stmt->execute([$domain]); + $id = $stmt->fetchColumn(); + if ($id) return (int)$id; + } + + // Fuzzy match by name + $stmt = $this->pdo->prepare("SELECT id FROM organizations WHERE name LIKE ? AND deleted_at IS NULL LIMIT 1"); + $stmt->execute(['%' . $name . '%']); + $id = $stmt->fetchColumn(); + return $id ? (int)$id : null; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c656976..fc39442 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -20,6 +20,9 @@ use App\Services\Database\Connection; use App\Services\Auth\AuthService; use App\Services\Auth\RBAC; use App\Services\Database\ActivityLogger; +use App\Services\Crawler\RssParser; +use App\Services\Crawler\AiAnalyzer; +use App\Services\Crawler\Collector; use App\Middleware\CsrfProtection; use App\Middleware\RateLimit; use App\Middleware\SecurityHeaders; @@ -35,6 +38,11 @@ $container->singleton(AuthService::class, AuthService::class); $container->singleton(RBAC::class, RBAC::class); $container->singleton(ActivityLogger::class, ActivityLogger::class); +// Crawler services +$container->singleton(RssParser::class, RssParser::class); +$container->singleton(AiAnalyzer::class, AiAnalyzer::class); +$container->singleton(Collector::class, Collector::class); + // Middleware bindings $container->bind(CsrfProtection::class, CsrfProtection::class); $container->bind(RateLimit::class, RateLimit::class); diff --git a/cli.php b/cli.php index 517508e..d7c090e 100644 --- a/cli.php +++ b/cli.php @@ -23,6 +23,9 @@ require_once __DIR__ . '/database/seeds/DatabaseSeeder.php'; use App\Core\Container; use App\Services\Database\Connection; use App\Services\Database\MigrationRunner; +use App\Services\Crawler\RssParser; +use App\Services\Crawler\AiAnalyzer; +use App\Services\Crawler\Collector; use Database\Seeds\DatabaseSeeder; $container = new Container(); @@ -32,6 +35,11 @@ $container->singleton(Connection::class, Connection::class); $container->singleton(MigrationRunner::class, MigrationRunner::class); $container->singleton(DatabaseSeeder::class, DatabaseSeeder::class); +// Crawler service (needed for CLI collect command) +$container->singleton(RssParser::class, RssParser::class); +$container->singleton(AiAnalyzer::class, AiAnalyzer::class); +$container->singleton(Collector::class, Collector::class); + $args = $argv; $command = $args[1] ?? 'help'; @@ -58,6 +66,32 @@ switch ($command) { } break; + case 'collect': + echo "=== Running Data Collector ===\n"; + try { + $collector = $container->get(Collector::class); + $results = $collector->collectAll(); + + echo "\n--- Results ---\n"; + echo "Sources processed: {$results['processed']}/{$results['total_sources']}\n"; + echo "Errors: {$results['errors']}\n"; + echo "New opportunities: {$results['new_opportunities']}\n"; + echo "New organizations: {$results['new_organizations']}\n\n"; + + foreach ($results['details'] as $detail) { + echo "[{$detail['status']}] {$detail['source']} ({$detail['type']}): "; + if ($detail['status'] === 'success') { + echo "{$detail['entries_found']} entries, {$detail['new_opportunities']} opps, {$detail['new_organizations']} orgs\n"; + } else { + echo "ERROR: {$detail['error']}\n"; + } + } + } catch (\Throwable $e) { + echo "Collection failed: " . $e->getMessage() . "\n"; + exit(1); + } + break; + case 'help': default: echo "ScoutIQ CLI Utility\n"; @@ -65,6 +99,7 @@ switch ($command) { echo "Commands:\n"; echo " migrate Run SQL database migrations\n"; echo " seed Seed the database with default data\n"; + echo " collect Run data collector (RSS feeds)\n"; echo " help Show this help menu\n"; break; } diff --git a/public/index.php b/public/index.php index 517d5cb..00f2e57 100644 --- a/public/index.php +++ b/public/index.php @@ -6,6 +6,10 @@ $app = require_once __DIR__ . '/../bootstrap/app.php'; use App\Controllers\HomeController; use App\Controllers\AuthController; use App\Controllers\Admin\DashboardController; +use App\Controllers\Admin\OrganizationsController; +use App\Controllers\Admin\OpportunitiesController; +use App\Controllers\Admin\ContactsController; +use App\Controllers\Admin\SourcesController; use App\Middleware\SecurityHeaders; use App\Middleware\RateLimit; use App\Middleware\CsrfProtection; @@ -34,11 +38,53 @@ $app->router->group([ 'prefix' => '/admin', 'middleware' => [Authenticate::class, CsrfProtection::class] ], function($r) { + // Dashboard $r->get('/dashboard', [DashboardController::class, 'index']); + + // Organizations CRUD + $r->get('/organizations', [OrganizationsController::class, 'index']); + $r->get('/organizations/create', [OrganizationsController::class, 'create']); + $r->post('/organizations/store', [OrganizationsController::class, 'store']); + $r->get('/organizations/{id}', [OrganizationsController::class, 'show']); + $r->get('/organizations/{id}/edit', [OrganizationsController::class, 'edit']); + $r->post('/organizations/{id}/update', [OrganizationsController::class, 'store']); + $r->get('/organizations/{id}/delete', [OrganizationsController::class, 'delete']); + + // Opportunities + $r->get('/opportunities', [OpportunitiesController::class, 'index']); + $r->get('/opportunities/{id}', [OpportunitiesController::class, 'show']); + + // Contacts CRUD + $r->get('/contacts', [ContactsController::class, 'index']); + $r->get('/contacts/create', [ContactsController::class, 'create']); + $r->post('/contacts/store', [ContactsController::class, 'store']); + $r->get('/contacts/{id}', [ContactsController::class, 'show']); + $r->get('/contacts/{id}/edit', [ContactsController::class, 'edit']); + $r->post('/contacts/{id}/update', [ContactsController::class, 'store']); + $r->get('/contacts/{id}/delete', [ContactsController::class, 'delete']); + $r->post('/contacts/{id}/interaction', [ContactsController::class, 'addInteraction']); + + // Sources + $r->get('/sources', [SourcesController::class, 'index']); + $r->get('/sources/create', [SourcesController::class, 'create']); + $r->post('/sources/store', [SourcesController::class, 'store']); + $r->get('/sources/{id}/edit', [SourcesController::class, 'edit']); + $r->post('/sources/{id}/update', [SourcesController::class, 'store']); + $r->get('/sources/{id}/delete', [SourcesController::class, 'delete']); + $r->get('/sources/{id}/run', [SourcesController::class, 'run']); }); // Logout endpoint $router->get('/logout', [AuthController::class, 'logout']); }); +// API Routes (no CSRF, uses JWT) +$app->router->group([ + 'prefix' => '/api', + 'middleware' => [RateLimit::class, Authenticate::class] +], function($r) { + $r->get('/organizations', [OrganizationsController::class, 'index']); + $r->get('/opportunities', [OpportunitiesController::class, 'index']); +}); + $app->run(); diff --git a/resources/views/admin/contacts/form.php b/resources/views/admin/contacts/form.php new file mode 100644 index 0000000..79a8a5b --- /dev/null +++ b/resources/views/admin/contacts/form.php @@ -0,0 +1,59 @@ + + +
+
+ + + + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/contacts/index.php b/resources/views/admin/contacts/index.php new file mode 100644 index 0000000..1021e94 --- /dev/null +++ b/resources/views/admin/contacts/index.php @@ -0,0 +1,67 @@ + + +session->getFlash('success')): ?> +
escape($flashSuccess) ?>
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NamePositionOrganizationEmailInteractionsActions
No contacts yet.
escape($contact['name']) ?>escape($contact['position'] ?? '-') ?>escape($contact['org_name']) : '-' ?>escape($contact['email']) : '-' ?> + View + Edit +
+
+ + $perPage): ?> + + + + \ No newline at end of file diff --git a/resources/views/admin/contacts/show.php b/resources/views/admin/contacts/show.php new file mode 100644 index 0000000..a684419 --- /dev/null +++ b/resources/views/admin/contacts/show.php @@ -0,0 +1,82 @@ + + +session->getFlash('success')): ?> +
escape($flashSuccess) ?>
+ + +
+
+

Contact Details

+
+ Email + escape($contact['email']) : '-' ?> +
+
+ Phone + escape($contact['phone']) : '-' ?> +
+
+ Position + escape($contact['position'] ?? '-') ?> +
+
+ Organization + ' . $this->escape($contact['org_name']) . '' : '-' ?> +
+
+ Notes +

escape($contact['notes'] ?? 'No notes') ?>

+
+
+ +
+ +
+

Interactions ()

+ +
+ +
+ + + +
+
+ + +

No interactions logged yet.

+ + +
+
+ escape($interaction['type']) ?> + +
+

escape($interaction['notes']) ?>

+
+ + +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/opportunities/index.php b/resources/views/admin/opportunities/index.php new file mode 100644 index 0000000..a2aabbb --- /dev/null +++ b/resources/views/admin/opportunities/index.php @@ -0,0 +1,101 @@ + + + +
+
+ + + + +
+
+ + +
+
+ Total + +
+ $c): ?> +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitleTypeSourceScoreStatusCreated
No opportunities yet. Run the collector first.
+ escape(mb_substr($opp['title'], 0, 60)) ?> + +
escape(implode(', ', explode(',', $opp['tag_names']))) ?> + +
escape($opp['type']) ?>escape($opp['org_name']) : '-' ?>escape($opp['status']) ?>Open
+
+ + + $perPage): ?> + + + + \ No newline at end of file diff --git a/resources/views/admin/opportunities/show.php b/resources/views/admin/opportunities/show.php new file mode 100644 index 0000000..9425881 --- /dev/null +++ b/resources/views/admin/opportunities/show.php @@ -0,0 +1,88 @@ + + +
+
+

Details

+
+ Status + escape($opportunity['status']) ?> +
+ + + +
+ URL + Open Link → +
+ +
+ Deadline + +
+ + +
+ Amount + $ +
+ +
+ Created + +
+ +
+ Tags +
+ + escape(trim($tag)) ?> + +
+
+ +
+ Description +

escape($opportunity['description']) ?>

+
+
+ +
+
+

Applications ()

+ +

No applications tracked yet.

+ + +
+
+ + escape($app['status']) ?> +
+ +

escape($app['notes']) ?>

+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/organizations/form.php b/resources/views/admin/organizations/form.php new file mode 100644 index 0000000..e45bed4 --- /dev/null +++ b/resources/views/admin/organizations/form.php @@ -0,0 +1,83 @@ + + +session->getFlash('error')): ?> +
escape($flashError) ?>
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/organizations/index.php b/resources/views/admin/organizations/index.php new file mode 100644 index 0000000..a004eca --- /dev/null +++ b/resources/views/admin/organizations/index.php @@ -0,0 +1,115 @@ + + +session->getFlash('success')): ?> +
escape($flashSuccess) ?>
+ +session->getFlash('error')): ?> +
escape($flashError) ?>
+ + + +
+
+ + + +
+
+ + +
+
+ Total Organizations + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeCountryCRM StatusOpportunitiesUpdatedActions
No organizations found. Run the collector or add one manually.
+ + escape($org['name']) ?> + + +
escape($org['domain']) ?> + +
escape($org['type']) ?>escape($org['country'] ?? '-') ?>escape($org['crm_status']) ?> + View + Edit + + Contact +
+
+ + + $perPage): ?> + + + + \ No newline at end of file diff --git a/resources/views/admin/organizations/show.php b/resources/views/admin/organizations/show.php new file mode 100644 index 0000000..ab0b3a3 --- /dev/null +++ b/resources/views/admin/organizations/show.php @@ -0,0 +1,108 @@ + + +session->getFlash('success')): ?> +
escape($flashSuccess) ?>
+ + +
+ +
+

Details

+
+ Domain + escape($org['domain']) : '-' ?> +
+
+ Website + escape($org['website_url']) . '" target="_blank">' . $this->escape($org['website_url']) . '' : '-' ?> +
+
+ Country + escape($org['country'] ?? '-') ?> +
+
+ City + escape($org['city'] ?? '-') ?> +
+
+ CRM Status + escape($org['crm_status']) ?> +
+
+ Funding Stage + escape($org['funding_stage'] ?? '-') ?> +
+
+ Created + +
+
+ Description +

escape($org['description'] ?? 'No description') ?>

+
+
+ + +
+
+

Opportunities ()

+ +

No opportunities linked to this organization yet.

+ + +
+
+ escape($opp['title']) ?> +
+ escape($opp['type']) ?> + Score: +
+
+
+ + +
+ +
+

Contacts ()

+ +

No contacts added yet.

+ + +
+
+ escape($contact['name']) ?> +
+ escape($contact['position'] ?? '') ?> + escape($contact['email']) : '' ?> +
+
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/sources/form.php b/resources/views/admin/sources/form.php new file mode 100644 index 0000000..3df5250 --- /dev/null +++ b/resources/views/admin/sources/form.php @@ -0,0 +1,46 @@ + + +
+
+ + + + + +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + Cancel +
+
+
+ + \ No newline at end of file diff --git a/resources/views/admin/sources/index.php b/resources/views/admin/sources/index.php new file mode 100644 index 0000000..c9bbd94 --- /dev/null +++ b/resources/views/admin/sources/index.php @@ -0,0 +1,60 @@ + + +session->getFlash('success')): ?> +
escape($flashSuccess) ?>
+ +session->getFlash('error')): ?> +
escape($flashError) ?>
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameURLTypeStatusActions
No data sources configured.
escape($source['name']) ?> + escape($source['url']) ?> + escape($source['type']) ?>escape($source['status']) ?> + Run + Edit + Delete +
+
+ + \ No newline at end of file