Review N8N V.2
This commit is contained in:
parent
26d8efd021
commit
156adbe5db
178
ai-call-logs.php
178
ai-call-logs.php
@ -46,123 +46,91 @@ $page_title = "AI Call Logs";
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header text-white">
|
||||
<div class="container-fluid">
|
||||
<h1 class="display-6 m-0"><?= htmlspecialchars($project_name) ?></h1>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="index.php">Dashboard</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="customers.php">Customers</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="bookings.php">Bookings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="ai-call-logs.php">AI Call Logs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<main class="container-fluid mt-4">
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> <?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i><?= htmlspecialchars($error) ?></div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="row g-4">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<i class="bi bi-telephone-fill display-4 text-primary me-3"></i>
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title">Total Calls</h5>
|
||||
<p class="card-text fs-2 fw-bold"><?= htmlspecialchars($total_calls) ?></p>
|
||||
<h6 class="text-muted">Total Calls</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_calls) ?></h4>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-phone-volume"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<i class="bi bi-clock-history display-4 text-info me-3"></i>
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title">Avg. Call Duration</h5>
|
||||
<p class="card-text fs-2 fw-bold"><?= htmlspecialchars(round($avg_call_duration)) ?>s</p>
|
||||
<h6 class="text-muted">Avg. Call Duration</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars(round($avg_call_duration)) ?>s</h4>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-clock"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-4 mt-2">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0">Call Intent Distribution</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="intentChart"></canvas>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Call Intent Distribution</h5></div>
|
||||
<div class="card-body"><canvas id="intentChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0">Call Outcome Distribution</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="outcomeChart"></canvas>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Call Outcome Distribution</h5></div>
|
||||
<div class="card-body"><canvas id="outcomeChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Call Logs Table -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0"><i class="bi bi-list-ul me-2"></i>All AI Call Logs</h5>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0"><i class="fas fa-list-ul me-2"></i>All AI Call Logs</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Call ID</th>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Intent</th>
|
||||
<th>Outcome</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
<thead>
|
||||
<tr><th>Call ID</th><th>Start Time</th><th>End Time</th><th>Intent</th><th>Outcome</th><th>Summary</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($call_logs as $log): ?>
|
||||
@ -170,15 +138,13 @@ $page_title = "AI Call Logs";
|
||||
<td><?= htmlspecialchars($log['call_id']) ?></td>
|
||||
<td><?= htmlspecialchars(date("M d, Y H:i:s", strtotime($log['call_start_time']))) ?></td>
|
||||
<td><?= htmlspecialchars(date("M d, Y H:i:s", strtotime($log['call_end_time']))) ?></td>
|
||||
<td><?= htmlspecialchars($log['call_intent']) ?></td>
|
||||
<td><?= htmlspecialchars($log['call_outcome']) ?></td>
|
||||
<td><?= htmlspecialchars($log['call_summary']) ?></td>
|
||||
<td><span class="badge bg-info"><?= htmlspecialchars($log['call_intent']) ?></span></td>
|
||||
<td><span class="badge bg-primary"><?= htmlspecialchars($log['call_outcome']) ?></span></td>
|
||||
<td><small><?= htmlspecialchars($log['call_summary']) ?></small></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($call_logs)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No call logs found.</td>
|
||||
</tr>
|
||||
<tr><td colspan="6" class="text-center text-muted">No call logs found.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -186,17 +152,19 @@ $page_title = "AI Call Logs";
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer class="container-fluid text-center text-muted py-3 mt-4">
|
||||
<small>Powered by Flatlogic</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
Chart.defaults.color = '#aab0bb';
|
||||
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||
|
||||
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
|
||||
|
||||
// Intent Chart
|
||||
const intentCtx = document.getElementById('intentChart').getContext('2d');
|
||||
new Chart(intentCtx, {
|
||||
@ -204,26 +172,15 @@ $page_title = "AI Call Logs";
|
||||
data: {
|
||||
labels: <?= json_encode(array_keys($intent_distribution)) ?>,
|
||||
datasets: [{
|
||||
label: 'Call Intents',
|
||||
data: <?= json_encode(array_values($intent_distribution)) ?>,
|
||||
backgroundColor: [
|
||||
'rgba(54, 162, 235, 0.8)',
|
||||
'rgba(255, 206, 86, 0.8)',
|
||||
'rgba(75, 192, 192, 0.8)',
|
||||
'rgba(153, 102, 255, 0.8)',
|
||||
'rgba(255, 159, 64, 0.8)'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
backgroundColor: chartBackgroundColor,
|
||||
borderColor: '#2d3446',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
}
|
||||
}
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
|
||||
}
|
||||
});
|
||||
|
||||
@ -234,30 +191,19 @@ $page_title = "AI Call Logs";
|
||||
data: {
|
||||
labels: <?= json_encode(array_keys($outcome_distribution)) ?>,
|
||||
datasets: [{
|
||||
label: 'Call Outcomes',
|
||||
data: <?= json_encode(array_values($outcome_distribution)) ?>,
|
||||
backgroundColor: [
|
||||
'rgba(75, 192, 192, 0.8)',
|
||||
'rgba(255, 99, 132, 0.8)',
|
||||
'rgba(255, 205, 86, 0.8)',
|
||||
'rgba(201, 203, 207, 0.8)',
|
||||
'rgba(54, 162, 235, 0.8)'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
backgroundColor: chartBackgroundColor,
|
||||
borderColor: '#2d3446',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
}
|
||||
}
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
54
api/brightlocal-sync.php
Normal file
54
api/brightlocal-sync.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php header('Content-Type: application/json');
|
||||
require_once 'config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Get the latest snapshot
|
||||
$stmt = $pdo->query("SELECT * FROM review_snapshot WHERE location_id = 'charlotte-heating' LIMIT 1");
|
||||
$snapshot = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Get recent reviews
|
||||
$stmt = $pdo->query("SELECT * FROM reviews WHERE location_id = 'charlotte-heating' ORDER BY review_date DESC LIMIT 10");
|
||||
$recentReviews = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($snapshot) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'total_reviews' => (int)$snapshot['total_reviews'],
|
||||
'avg_rating' => (float)$snapshot['avg_rating'],
|
||||
'reviews_this_week' => (int)$snapshot['reviews_this_week'],
|
||||
'reviews_this_month' => (int)$snapshot['reviews_this_month'],
|
||||
'sources' => [
|
||||
'google' => ['count' => (int)$snapshot['google_reviews'], 'avg' => (float)$snapshot['google_avg']],
|
||||
'yelp' => ['count' => (int)$snapshot['yelp_reviews'], 'avg' => (float)$snapshot['yelp_avg']],
|
||||
'facebook' => ['count' => (int)$snapshot['facebook_reviews'], 'avg' => (float)$snapshot['facebook_avg']]
|
||||
],
|
||||
'recent_reviews' => $recentReviews,
|
||||
'last_synced' => $snapshot['last_synced']
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
// Return default data if no snapshot exists yet
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'total_reviews' => 504,
|
||||
'avg_rating' => 4.93,
|
||||
'reviews_this_week' => 0,
|
||||
'reviews_this_month' => 0,
|
||||
'sources' => [
|
||||
'google' => ['count' => 500, 'avg' => 4.95],
|
||||
'yelp' => ['count' => 2, 'avg' => 3.0],
|
||||
'facebook' => ['count' => 2, 'avg' => 3.0]
|
||||
],
|
||||
'recent_reviews' => [],
|
||||
'last_synced' => null,
|
||||
'message' => 'Using default data - N8N sync not yet configured'
|
||||
]
|
||||
]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
82
api/calendar-events.php
Normal file
82
api/calendar-events.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$apiKey = get_api_key();
|
||||
if (!$apiKey) {
|
||||
error_log('API key is missing or not configured.');
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'API key is not configured.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (strpos($authHeader, 'Bearer ') !== 0) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authorization header missing or invalid.']);
|
||||
exit;
|
||||
}
|
||||
$token = substr($authHeader, 7);
|
||||
if ($token !== $apiKey) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid API key.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
log_api_request('calendar-events');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Basic validation
|
||||
$required_fields = ['google_event_id', 'start_datetime', 'end_datetime'];
|
||||
foreach ($required_fields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => "Missing required field: $field"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO calendar_events (google_event_id, google_calendar_id, event_title, event_description, event_location, start_datetime, end_datetime, customer_name, customer_phone, service_type, assigned_technician, event_status, booking_id)
|
||||
VALUES (:google_event_id, :google_calendar_id, :event_title, :event_description, :event_location, :start_datetime, :end_datetime, :customer_name, :customer_phone, :service_type, :assigned_technician, :event_status, :booking_id)"
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':google_event_id' => $data['google_event_id'],
|
||||
':google_calendar_id' => $data['google_calendar_id'] ?? null,
|
||||
':event_title' => $data['event_title'] ?? null,
|
||||
':event_description' => $data['event_description'] ?? null,
|
||||
':event_location' => $data['event_location'] ?? null,
|
||||
':start_datetime' => $data['start_datetime'],
|
||||
':end_datetime' => $data['end_datetime'],
|
||||
':customer_name' => $data['customer_name'] ?? null,
|
||||
':customer_phone' => $data['customer_phone'] ?? null,
|
||||
':service_type' => $data['service_type'] ?? null,
|
||||
':assigned_technician' => $data['assigned_technician'] ?? null,
|
||||
':event_status' => $data['event_status'] ?? null,
|
||||
':booking_id' => $data['booking_id'] ?? null,
|
||||
]);
|
||||
|
||||
http_response_code(201);
|
||||
echo json_encode(['success' => true, 'message' => 'Calendar event created successfully.']);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() == 23000) { // Duplicate entry
|
||||
http_response_code(409);
|
||||
echo json_encode(['error' => 'Duplicate google_event_id.']);
|
||||
} else {
|
||||
error_log("DB Error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database error.']);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
http_response_code(405); // Method Not Allowed
|
||||
echo json_encode(['error' => 'Only POST method is accepted.']);
|
||||
}
|
||||
@ -1,64 +1,68 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJsonResponse(['error' => 'Invalid request method'], 405);
|
||||
exit;
|
||||
}
|
||||
check_api_key();
|
||||
|
||||
if (!validateApiKey()) {
|
||||
logWebhook('call-tracking', file_get_contents('php://input'), 401);
|
||||
sendJsonResponse(['error' => 'Unauthorized'], 401);
|
||||
exit;
|
||||
}
|
||||
$request_method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$request_body = file_get_contents('php://input');
|
||||
$data = json_decode($request_body, true);
|
||||
if ($request_method === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
logWebhook('call-tracking', $request_body, 400);
|
||||
sendJsonResponse(['error' => 'Invalid JSON'], 400);
|
||||
exit;
|
||||
}
|
||||
// --- Validation ---
|
||||
$required_fields = ['external_call_id', 'call_start_time'];
|
||||
foreach ($required_fields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
log_and_exit(400, "Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
if (empty($data['external_call_id'])) {
|
||||
$errors[] = 'external_call_id is required';
|
||||
}
|
||||
if (empty($data['tracking_platform'])) {
|
||||
$errors[] = 'tracking_platform is required';
|
||||
}
|
||||
if (empty($data['call_start_time'])) {
|
||||
$errors[] = 'call_start_time is required';
|
||||
}
|
||||
if (empty($data['call_status'])) {
|
||||
$errors[] = 'call_status is required';
|
||||
}
|
||||
if (empty($data['traffic_source'])) {
|
||||
$errors[] = 'traffic_source is required';
|
||||
}
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO call_tracking (
|
||||
external_call_id, tracking_platform, caller_number, tracking_number,
|
||||
call_start_time, call_end_time, call_duration_seconds, call_status,
|
||||
answered_by, traffic_source, campaign_name, recording_url,
|
||||
was_ai_rescue, attributed_revenue, caller_name, caller_city, caller_state
|
||||
) VALUES (
|
||||
:external_call_id, :tracking_platform, :caller_number, :tracking_number,
|
||||
:call_start_time, :call_end_time, :call_duration_seconds, :call_status,
|
||||
:answered_by, :traffic_source, :campaign_name, :recording_url,
|
||||
:was_ai_rescue, :attributed_revenue, :caller_name, :caller_city, :caller_state
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':external_call_id' => $data['external_call_id'],
|
||||
':tracking_platform' => $data['tracking_platform'] ?? null,
|
||||
':caller_number' => $data['caller_number'] ?? null,
|
||||
':tracking_number' => $data['tracking_number'] ?? null,
|
||||
':call_start_time' => $data['call_start_time'],
|
||||
':call_end_time' => $data['call_end_time'] ?? null,
|
||||
':call_duration_seconds' => $data['call_duration_seconds'] ?? null,
|
||||
':call_status' => $data['call_status'] ?? null,
|
||||
':answered_by' => $data['answered_by'] ?? null,
|
||||
':traffic_source' => $data['traffic_source'] ?? null,
|
||||
':campaign_name' => $data['campaign_name'] ?? null,
|
||||
':recording_url' => $data['recording_url'] ?? null,
|
||||
':was_ai_rescue' => $data['was_ai_rescue'] ?? 0,
|
||||
':attributed_revenue' => $data['attributed_revenue'] ?? null,
|
||||
':caller_name' => $data['caller_name'] ?? null,
|
||||
':caller_city' => $data['caller_city'] ?? null,
|
||||
':caller_state' => $data['caller_state'] ?? null,
|
||||
]);
|
||||
|
||||
if (!empty($errors)) {
|
||||
logWebhook('call-tracking', $request_body, 422);
|
||||
sendJsonResponse(['errors' => $errors], 422);
|
||||
exit;
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'message' => 'Call tracked successfully.', 'id' => $pdo->lastInsertId()]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($e->errorInfo[1] == 1062) { // Duplicate entry
|
||||
log_and_exit(409, "Conflict: A call with the same external_call_id already exists.");
|
||||
} else {
|
||||
log_and_exit(500, "Database error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
log_and_exit(405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = db()->prepare("INSERT INTO call_tracking (external_call_id, tracking_platform, call_start_time, call_status, traffic_source) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([
|
||||
$data['external_call_id'],
|
||||
$data['tracking_platform'],
|
||||
$data['call_start_time'],
|
||||
$data['call_status'],
|
||||
$data['traffic_source']
|
||||
]);
|
||||
$new_id = db()->lastInsertId();
|
||||
logWebhook('call-tracking', $request_body, 201);
|
||||
sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Call tracking created'], 201);
|
||||
} catch (PDOException $e) {
|
||||
error_log($e->getMessage());
|
||||
logWebhook('call-tracking', $request_body, 500);
|
||||
sendJsonResponse(['error' => 'Database error'], 500);
|
||||
}
|
||||
84
api/chat-logs.php
Normal file
84
api/chat-logs.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
// Prevent any output before headers
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Handle non-POST requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
die(json_encode(['error' => 'Method not allowed']));
|
||||
}
|
||||
|
||||
// Get request body
|
||||
$body = file_get_contents('php://input');
|
||||
|
||||
// Always log to file first (this works)
|
||||
$logFile = __DIR__ . '/webhook_debug.log';
|
||||
file_put_contents($logFile, date('Y-m-d H:i:s') . " - " . $body . "
|
||||
", FILE_APPEND);
|
||||
|
||||
// Try database, but don't fail if it doesn't work
|
||||
$dbSuccess = false;
|
||||
$dbError = null;
|
||||
$insertId = null;
|
||||
|
||||
try {
|
||||
// Check if config exists
|
||||
$configPath = __DIR__ . '/config.php';
|
||||
if (!file_exists($configPath)) {
|
||||
throw new Exception("config.php not found at: " . $configPath);
|
||||
}
|
||||
require_once $configPath;
|
||||
|
||||
// Check if $pdo exists
|
||||
if (!isset($pdo)) {
|
||||
throw new Exception("PDO connection not established in config.php");
|
||||
}
|
||||
|
||||
// Parse Tiny Talk payload
|
||||
$data = json_decode($body, true);
|
||||
$eventType = $data['type'] ?? 'unknown';
|
||||
$payload = $data['payload'] ?? [];
|
||||
|
||||
// Extract fields
|
||||
$externalChatId = $payload['id'] ?? null;
|
||||
|
||||
// Get customer name
|
||||
$firstName = $payload['name']['first'] ?? '';
|
||||
$lastName = $payload['name']['last'] ?? '';
|
||||
$customerName = trim($firstName . ' ' . $lastName);
|
||||
if (empty($customerName)) $customerName = 'Unknown';
|
||||
|
||||
// Get email and phone
|
||||
$customerEmail = $payload['email']['address'] ?? $payload['email']['pendingAddress'] ?? null;
|
||||
$customerPhone = $payload['phone']['number'] ?? null;
|
||||
|
||||
// Parse the ISO date properly
|
||||
$createdAt = $payload['createdAt'] ?? null;
|
||||
if ($createdAt) {
|
||||
$createdAt = date('Y-m-d H:i:s', strtotime($createdAt));
|
||||
} else {
|
||||
$createdAt = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Insert with all fields
|
||||
$stmt = $pdo->prepare("INSERT INTO chat_logs (external_chat_id, event_type, customer_name, customer_email, customer_phone, raw_payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$externalChatId, $eventType, $customerName, $customerEmail, $customerPhone, $body, $createdAt]);
|
||||
$insertId = $pdo->lastInsertId();
|
||||
$dbSuccess = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$dbError = $e->getMessage();
|
||||
}
|
||||
|
||||
// Always return 200
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'logged_to_file' => true,
|
||||
'db_success' => $dbSuccess,
|
||||
'db_error' => $dbError,
|
||||
'insert_id' => $insertId
|
||||
]);
|
||||
@ -46,3 +46,6 @@ function sendJsonResponse($data, $statusCode = 200) {
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create the $pdo variable for scripts that need it.
|
||||
$pdo = db();
|
||||
92
api/reviews-webhook.php
Normal file
92
api/reviews-webhook.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../db/config.php';
|
||||
|
||||
// Get the incoming data
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (!$data) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No data received or invalid JSON.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Handle snapshot update
|
||||
if (isset($data['type']) && $data['type'] === 'snapshot') {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO review_snapshot (location_id, total_reviews, avg_rating, reviews_this_week, reviews_this_month, google_reviews, google_avg, yelp_reviews, yelp_avg, facebook_reviews, facebook_avg, last_synced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_reviews = VALUES(total_reviews),
|
||||
avg_rating = VALUES(avg_rating),
|
||||
reviews_this_week = VALUES(reviews_this_week),
|
||||
reviews_this_month = VALUES(reviews_this_month),
|
||||
google_reviews = VALUES(google_reviews),
|
||||
google_avg = VALUES(google_avg),
|
||||
yelp_reviews = VALUES(yelp_reviews),
|
||||
yelp_avg = VALUES(yelp_avg),
|
||||
facebook_reviews = VALUES(facebook_reviews),
|
||||
facebook_avg = VALUES(facebook_avg),
|
||||
last_synced = NOW()
|
||||
");
|
||||
$stmt->execute([
|
||||
$data['location_id'] ?? 'charlotte-heating',
|
||||
$data['total_reviews'] ?? 0,
|
||||
$data['avg_rating'] ?? 0,
|
||||
$data['reviews_this_week'] ?? 0,
|
||||
$data['reviews_this_month'] ?? 0,
|
||||
$data['google_reviews'] ?? 0,
|
||||
$data['google_avg'] ?? 0,
|
||||
$data['yelp_reviews'] ?? 0,
|
||||
$data['yelp_avg'] ?? 0,
|
||||
$data['facebook_reviews'] ?? 0,
|
||||
$data['facebook_avg'] ?? 0
|
||||
]);
|
||||
echo json_encode(['success' => true, 'message' => 'Snapshot updated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Handle individual review insert
|
||||
if (isset($data['type']) && $data['type'] === 'review') {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT IGNORE INTO reviews (location_id, site, review_id, reviewer_name, rating, review_text, review_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$data['location_id'] ?? 'charlotte-heating',
|
||||
$data['site'] ?? 'google',
|
||||
$data['review_id'] ?? uniqid(),
|
||||
$data['reviewer_name'] ?? 'Anonymous',
|
||||
$data['rating'] ?? null,
|
||||
$data['review_text'] ?? '',
|
||||
$data['review_date'] ?? null
|
||||
]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
echo json_encode(['success' => true, 'message' => 'Review inserted']);
|
||||
} else {
|
||||
echo json_encode(['success' => true, 'message' => 'Review skipped (already exists)']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fallback for unknown type
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid data type specified.']);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
// In a real app, you'd log this securely.
|
||||
error_log('Webhook DB Error: ' . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'error' => 'A database error occurred.']);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log('Webhook General Error: ' . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'error' => 'An internal server error occurred.']);
|
||||
}
|
||||
|
||||
?>
|
||||
102
api/reviews.php
102
api/reviews.php
@ -1,55 +1,55 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJsonResponse(['error' => 'Invalid request method'], 405);
|
||||
exit;
|
||||
check_api_key();
|
||||
|
||||
$request_method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($request_method === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Basic validation
|
||||
if (empty($data['platform_source']) || empty($data['star_rating'])) {
|
||||
log_and_exit(400, "Missing required fields: platform_source and star_rating are required.");
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO reviews (
|
||||
external_review_id, platform_source, star_rating, review_text, reviewer_name,
|
||||
review_date, review_url, sentiment_score, was_ai_booked, is_negative_alert,
|
||||
response_needed, response_sent, response_text
|
||||
) VALUES (
|
||||
:external_review_id, :platform_source, :star_rating, :review_text, :reviewer_name,
|
||||
:review_date, :review_url, :sentiment_score, :was_ai_booked, :is_negative_alert,
|
||||
:response_needed, :response_sent, :response_text
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':external_review_id' => $data['external_review_id'] ?? null,
|
||||
':platform_source' => $data['platform_source'],
|
||||
':star_rating' => $data['star_rating'],
|
||||
':review_text' => $data['review_text'] ?? null,
|
||||
':reviewer_name' => $data['reviewer_name'] ?? null,
|
||||
':review_date' => $data['review_date'] ?? null,
|
||||
':review_url' => $data['review_url'] ?? null,
|
||||
':sentiment_score' => $data['sentiment_score'] ?? null,
|
||||
':was_ai_booked' => $data['was_ai_booked'] ?? 0,
|
||||
':is_negative_alert' => ($data['star_rating'] <= 2) ? 1 : 0,
|
||||
':response_needed' => $data['response_needed'] ?? 0,
|
||||
':response_sent' => $data['response_sent'] ?? 0,
|
||||
':response_text' => $data['response_text'] ?? null,
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'message' => 'Review logged successfully.', 'id' => $pdo->lastInsertId()]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
log_and_exit(500, "Database error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
} else {
|
||||
log_and_exit(405, "Method Not Allowed");
|
||||
}
|
||||
|
||||
if (!validateApiKey()) {
|
||||
logWebhook('reviews', file_get_contents('php://input'), 401);
|
||||
sendJsonResponse(['error' => 'Unauthorized'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$request_body = file_get_contents('php://input');
|
||||
$data = json_decode($request_body, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
logWebhook('reviews', $request_body, 400);
|
||||
sendJsonResponse(['error' => 'Invalid JSON'], 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
if (empty($data['platform_source'])) {
|
||||
$errors[] = 'platform_source is required';
|
||||
}
|
||||
if (empty($data['star_rating'])) {
|
||||
$errors[] = 'star_rating is required';
|
||||
}
|
||||
|
||||
|
||||
if (!empty($errors)) {
|
||||
logWebhook('reviews', $request_body, 422);
|
||||
sendJsonResponse(['errors' => $errors], 422);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = db()->prepare("INSERT INTO reviews (platform_source, star_rating, review_text, reviewer_name, review_date) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([
|
||||
$data['platform_source'],
|
||||
$data['star_rating'],
|
||||
$data['review_text'] ?? null,
|
||||
$data['reviewer_name'] ?? null,
|
||||
$data['review_date'] ?? null
|
||||
]);
|
||||
$new_id = db()->lastInsertId();
|
||||
logWebhook('reviews', $request_body, 201);
|
||||
sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Review created'], 201);
|
||||
} catch (PDOException $e) {
|
||||
error_log($e->getMessage());
|
||||
logWebhook('reviews', $request_body, 500);
|
||||
sendJsonResponse(['error' => 'Database error'], 500);
|
||||
}
|
||||
1
api/view-webhook-log.php
Normal file
1
api/view-webhook-log.php
Normal file
@ -0,0 +1 @@
|
||||
<?php header('Content-Type: text/plain'); $logFile = __DIR__ . '/webhook_debug.log'; if (file_exists($logFile)) { echo file_get_contents($logFile); } else { echo "No log file found"; }
|
||||
82
api/weather-fetch.php
Normal file
82
api/weather-fetch.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// --- OpenWeatherMap API Call ---
|
||||
$openweathermap_api_key = 'ff101be91e4bbe53d6ffbbec1868dfc0';
|
||||
$api_url = "https://api.openweathermap.org/data/3.0/onecall?lat=35.2271&lon=-80.8431&units=imperial&exclude=minutely,hourly&appid={$openweathermap_api_key}";
|
||||
|
||||
$response = @file_get_contents($api_url);
|
||||
if ($response === false) {
|
||||
http_response_code(502); // Bad Gateway
|
||||
echo json_encode(['error' => 'Failed to fetch data from OpenWeatherMap API.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$weather_data = json_decode($response, true);
|
||||
|
||||
if ($weather_data === null || !isset($weather_data['current'])) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Invalid response from OpenWeatherMap API.', 'details' => $weather_data]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Data Transformation & Storage ---
|
||||
$current_weather = $weather_data['current'];
|
||||
$temperature_f = $current_weather['temp'];
|
||||
$is_extreme_heat = ($temperature_f > 95);
|
||||
$is_extreme_cold = ($temperature_f < 32);
|
||||
|
||||
$weather_record = [
|
||||
':location_name' => "Charlotte, NC",
|
||||
':zip_code' => "28202",
|
||||
':observation_time' => date('Y-m-d H:i:s', $current_weather['dt'] ?? time()),
|
||||
':weather_condition' => $current_weather['weather'][0]['main'] ?? null,
|
||||
':weather_description' => $current_weather['weather'][0]['description'] ?? null,
|
||||
':weather_icon' => $current_weather['weather'][0]['icon'] ?? null,
|
||||
':temperature_f' => $temperature_f,
|
||||
':feels_like_f' => $current_weather['feels_like'] ?? null,
|
||||
':temp_min_f' => $weather_data['daily'][0]['temp']['min'] ?? null, // Approximating from daily
|
||||
':temp_max_f' => $weather_data['daily'][0]['temp']['max'] ?? null, // Approximating from daily
|
||||
':humidity_pct' => $current_weather['humidity'] ?? null,
|
||||
':wind_speed_mph' => $current_weather['wind_speed'] ?? null,
|
||||
':is_extreme_heat' => $is_extreme_heat ? 1 : 0,
|
||||
':is_extreme_cold' => $is_extreme_cold ? 1 : 0,
|
||||
':is_severe_weather' => isset($weather_data['alerts']) ? 1 : 0,
|
||||
':weather_alerts' => isset($weather_data['alerts']) ? json_encode($weather_data['alerts']) : null,
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO weather (zip_code, location_name, observation_time, weather_condition, weather_description, weather_icon, temperature_f, feels_like_f, temp_min_f, temp_max_f, humidity_pct, wind_speed_mph, is_extreme_heat, is_extreme_cold, is_severe_weather, weather_alerts)
|
||||
VALUES (:zip_code, :location_name, :observation_time, :weather_condition, :weather_description, :weather_icon, :temperature_f, :feels_like_f, :temp_min_f, :temp_max_f, :humidity_pct, :wind_speed_mph, :is_extreme_heat, :is_extreme_cold, :is_severe_weather, :weather_alerts)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
location_name = VALUES(location_name),
|
||||
observation_time = VALUES(observation_time),
|
||||
weather_condition = VALUES(weather_condition),
|
||||
weather_description = VALUES(weather_description),
|
||||
weather_icon = VALUES(weather_icon),
|
||||
temperature_f = VALUES(temperature_f),
|
||||
feels_like_f = VALUES(feels_like_f),
|
||||
temp_min_f = VALUES(temp_min_f),
|
||||
temp_max_f = VALUES(temp_max_f),
|
||||
humidity_pct = VALUES(humidity_pct),
|
||||
wind_speed_mph = VALUES(wind_speed_mph),
|
||||
is_extreme_heat = VALUES(is_extreme_heat),
|
||||
is_extreme_cold = VALUES(is_extreme_cold),
|
||||
is_severe_weather = VALUES(is_severe_weather),
|
||||
weather_alerts = VALUES(weather_alerts)
|
||||
");
|
||||
|
||||
$stmt->execute($weather_record);
|
||||
|
||||
// Return the fresh data
|
||||
echo json_encode($weather_record);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("DB Error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database error while saving weather data.']);
|
||||
}
|
||||
70
api/weather.php
Normal file
70
api/weather.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$apiKey = get_api_key();
|
||||
if (!$apiKey) {
|
||||
error_log('API key is missing or not configured.');
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'API key is not configured.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (strpos($authHeader, 'Bearer ') !== 0) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authorization header missing or invalid.']);
|
||||
exit;
|
||||
}
|
||||
$token = substr($authHeader, 7);
|
||||
if ($token !== $apiKey) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid API key.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
log_api_request('weather');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO weather (location_name, zip_code, observation_time, weather_condition, weather_description, weather_icon, temperature_f, feels_like_f, temp_min_f, temp_max_f, humidity_pct, wind_speed_mph, is_extreme_heat, is_extreme_cold, is_severe_weather, weather_alerts)
|
||||
VALUES (:location_name, :zip_code, :observation_time, :weather_condition, :weather_description, :weather_icon, :temperature_f, :feels_like_f, :temp_min_f, :temp_max_f, :humidity_pct, :wind_speed_mph, :is_extreme_heat, :is_extreme_cold, :is_severe_weather, :weather_alerts)"
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':location_name' => $data['location_name'] ?? null,
|
||||
':zip_code' => $data['zip_code'] ?? null,
|
||||
':observation_time' => $data['observation_time'] ?? null,
|
||||
':weather_condition' => $data['weather_condition'] ?? null,
|
||||
':weather_description' => $data['weather_description'] ?? null,
|
||||
':weather_icon' => $data['weather_icon'] ?? null,
|
||||
':temperature_f' => $data['temperature_f'] ?? null,
|
||||
':feels_like_f' => $data['feels_like_f'] ?? null,
|
||||
':temp_min_f' => $data['temp_min_f'] ?? null,
|
||||
':temp_max_f' => $data['temp_max_f'] ?? null,
|
||||
':humidity_pct' => $data['humidity_pct'] ?? null,
|
||||
':wind_speed_mph' => $data['wind_speed_mph'] ?? null,
|
||||
':is_extreme_heat' => $data['is_extreme_heat'] ?? 0,
|
||||
':is_extreme_cold' => $data['is_extreme_cold'] ?? 0,
|
||||
':is_severe_weather' => $data['is_severe_weather'] ?? 0,
|
||||
':weather_alerts' => isset($data['weather_alerts']) ? json_encode($data['weather_alerts']) : null,
|
||||
]);
|
||||
|
||||
http_response_code(201);
|
||||
echo json_encode(['success' => true, 'message' => 'Weather data created successfully.']);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("DB Error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database error.']);
|
||||
}
|
||||
} else {
|
||||
http_response_code(405); // Method Not Allowed
|
||||
echo json_encode(['error' => 'Only POST method is accepted.']);
|
||||
}
|
||||
97
api/webhook_debug.log
Normal file
97
api/webhook_debug.log
Normal file
@ -0,0 +1,97 @@
|
||||
2026-01-06 16:53:54 - {"event":"test","data":{"name":"Test"}}
|
||||
2026-01-06 16:55:09 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-06T16:55:08.937Z","updatedAt":"2026-01-06T16:55:08.937Z"}}
|
||||
2026-01-06 16:55:28 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-06T16:55:28.102Z","updatedAt":"2026-01-06T16:55:28.102Z"}}
|
||||
2026-01-06 17:06:46 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-123",
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": {"address": "john@example.com"},
|
||||
"phone": {"number": "704-555-1234"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-06 17:06:58 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-123",
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": {"address": "john@example.com"},
|
||||
"phone": {"number": "704-555-1234"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-06 17:12:03 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-123",
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": {"address": "john@example.com"},
|
||||
"phone": {"number": "704-555-1234"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-06 17:16:42 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-123",
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": {"address": "john@example.com"},
|
||||
"phone": {"number": "704-555-1234"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-07 02:09:06 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-456",
|
||||
"name": {"first": "Jane", "last": "Smith"},
|
||||
"email": {"address": "jane@example.com"},
|
||||
"phone": {"number": "704-555-5678"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-07 02:17:54 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-789",
|
||||
"name": {"first": "Mike", "last": "Johnson"},
|
||||
"email": {"address": "mike@example.com"},
|
||||
"phone": {"number": "704-555-9999"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-07 02:24:17 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-final",
|
||||
"name": {"first": "Sarah", "last": "Wilson"},
|
||||
"email": {"address": "sarah@example.com"},
|
||||
"phone": {"number": "704-555-1111"},
|
||||
"createdAt": "2026-01-06T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-07 02:27:05 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T02:27:05.059Z","updatedAt":"2026-01-07T02:27:05.059Z"}}
|
||||
2026-01-07 15:37:54 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T15:37:53.921Z","updatedAt":"2026-01-07T15:37:53.921Z"}}
|
||||
2026-01-07 15:38:28 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T15:38:28.142Z","updatedAt":"2026-01-07T15:38:28.142Z"}}
|
||||
2026-01-07 15:43:08 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-display-check",
|
||||
"name": {"first": "David", "last": "Thompson"},
|
||||
"email": {"address": "david@example.com"},
|
||||
"phone": {"number": "704-555-2222"},
|
||||
"createdAt": "2026-01-06T21:30:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-08 05:03:27 - {
|
||||
"type": "contact.created",
|
||||
"payload": {
|
||||
"id": "test-display-check",
|
||||
"name": {"first": "David", "last": "Thompson"},
|
||||
"email": {"address": "david@example.com"},
|
||||
"phone": {"number": "704-555-2222"},
|
||||
"createdAt": "2026-01-06T21:30:00.000Z"
|
||||
}
|
||||
}
|
||||
2026-01-08 05:03:47 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-08T05:03:47.257Z","updatedAt":"2026-01-08T05:03:47.257Z"}}
|
||||
@ -1,47 +1,156 @@
|
||||
/* HVAC Command Center Custom Styles */
|
||||
/* HVAC Command Center - Dark Theme */
|
||||
|
||||
:root {
|
||||
--dark-bg: #1a1f2b;
|
||||
--card-bg: #2d3446;
|
||||
--text-light: #ffffff;
|
||||
--text-muted: #aab0bb;
|
||||
--accent-color: #4dc9ff;
|
||||
--border-color: #3d4455;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--text-light);
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(90deg, #0d6efd, #17a2b8);
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 8px 24px rgba(77, 201, 255, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: linear-gradient(90deg, #364057, #2d3446);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f1f3f5;
|
||||
.kpi-card-header {
|
||||
background: linear-gradient(135deg, rgba(77, 201, 255, 0.15), rgba(77, 201, 255, 0.05));
|
||||
}
|
||||
|
||||
.badge.bg-emergency {
|
||||
background-color: #dc3545 !important;
|
||||
.table {
|
||||
--bs-table-bg: var(--card-bg);
|
||||
--bs-table-color: var(--text-light);
|
||||
--bs-table-border-color: var(--border-color);
|
||||
--bs-table-hover-bg: #364057;
|
||||
--bs-table-hover-color: var(--text-light);
|
||||
}
|
||||
|
||||
.badge.bg-urgent {
|
||||
background-color: #ffc107 !important;
|
||||
color: #000 !important;
|
||||
.table-striped>tbody>tr:nth-of-type(odd)>* {
|
||||
--bs-table-accent-bg: #262c3a;
|
||||
}
|
||||
|
||||
.badge.bg-routine {
|
||||
background-color: #6c757d !important;
|
||||
.badge.bg-success { background-color: #28a745 !important; }
|
||||
.badge.bg-warning { background-color: #ffc107 !important; color: #000 !important; }
|
||||
.badge.bg-danger { background-color: #dc3545 !important; }
|
||||
.badge.bg-info { background-color: var(--accent-color) !important; color: #000 !important;}
|
||||
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
background-color: var(--card-bg);
|
||||
padding: 1rem;
|
||||
z-index: 1030;
|
||||
border-right: 1px solid var(--border-color);
|
||||
box-shadow: 4px 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
background-color: rgba(77, 201, 255, 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 250px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
z-index: 1031;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stat Cards */
|
||||
.stat-card .icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--accent-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.stat-card .change-indicator {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.stat-card .change-indicator.positive { color: #28a745; }
|
||||
.stat-card .change-indicator.negative { color: #dc3545; }
|
||||
|
||||
/* Weather Widget */
|
||||
#weather-widget {
|
||||
background: linear-gradient(135deg, #2d3446, #1a1f2b);
|
||||
}
|
||||
|
||||
/* Map Widget */
|
||||
#weather-map {
|
||||
height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.leaflet-tile {
|
||||
filter: brightness(0.8) contrast(1.2);
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
@ -1 +1,86 @@
|
||||
// Future javascript for the HVAC Command Center
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const refreshButton = document.getElementById('refresh-weather-btn');
|
||||
const weatherContent = document.getElementById('weather-content');
|
||||
const weatherLoading = document.getElementById('weather-loading');
|
||||
const weatherError = document.getElementById('weather-error');
|
||||
|
||||
const weatherIcon = document.getElementById('weather-icon');
|
||||
const weatherTemp = document.getElementById('weather-temp');
|
||||
const weatherFeelsLike = document.getElementById('weather-feels-like');
|
||||
const weatherDesc = document.getElementById('weather-desc');
|
||||
const weatherLastUpdated = document.getElementById('weather-last-updated');
|
||||
const weatherAlerts = document.getElementById('weather-alerts');
|
||||
const weatherPlaceholder = document.getElementById('weather-placeholder');
|
||||
|
||||
async function fetchWeather() {
|
||||
// Show loading state
|
||||
weatherContent.style.display = 'none';
|
||||
weatherError.style.display = 'none';
|
||||
weatherLoading.style.display = 'block';
|
||||
refreshButton.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('api/weather-fetch.php');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
if (weatherPlaceholder) {
|
||||
weatherPlaceholder.style.display = 'none';
|
||||
}
|
||||
weatherIcon.src = `https://openweathermap.org/img/wn/${data[':weather_icon']}@2x.png`;
|
||||
weatherTemp.innerHTML = `${Math.round(data[':temperature_f'])}°F`;
|
||||
weatherFeelsLike.innerHTML = `Feels like ${Math.round(data[':feels_like_f'])}°F`;
|
||||
weatherDesc.textContent = data[':weather_description'].charAt(0).toUpperCase() + data[':weather_description'].slice(1);
|
||||
|
||||
const observationDate = new Date(data[':observation_time'].replace(/-/g, '/'));
|
||||
weatherLastUpdated.textContent = observationDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
// Clear and build alerts
|
||||
weatherAlerts.innerHTML = '';
|
||||
if (data[':is_extreme_heat'] == 1) {
|
||||
weatherAlerts.innerHTML += `
|
||||
<div class="alert alert-danger mt-3 mb-0" role="alert">
|
||||
<i class="bi bi-fire"></i> <strong>High AC Demand Expected:</strong> Temp > 95°F.
|
||||
</div>`;
|
||||
}
|
||||
if (data[':is_extreme_cold'] == 1) {
|
||||
weatherAlerts.innerHTML += `
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<i class="bi bi-snow"></i> <strong>High Heating Demand Expected:</strong> Temp < 32°F.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
weatherContent.style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching weather:", error);
|
||||
weatherError.textContent = 'Failed to fetch weather data. Please try again.';
|
||||
weatherError.style.display = 'block';
|
||||
} finally {
|
||||
// Hide loading state
|
||||
weatherLoading.style.display = 'none';
|
||||
refreshButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
if(refreshButton) {
|
||||
refreshButton.addEventListener('click', fetchWeather);
|
||||
}
|
||||
|
||||
|
||||
// --- Auto-refresh Timer ---
|
||||
// Auto-refresh every 30 minutes (1800000 milliseconds)
|
||||
setInterval(fetchWeather, 1800000);
|
||||
});
|
||||
|
||||
38
assets/js/map.js
Normal file
38
assets/js/map.js
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
var map;
|
||||
var layers;
|
||||
var currentLayer = null;
|
||||
var owmApiKey = 'ff101be91e4bbe53d6ffbbec1868dfc0';
|
||||
|
||||
function showLayer(layerName) {
|
||||
if (currentLayer) {
|
||||
map.removeLayer(currentLayer);
|
||||
}
|
||||
if (layers[layerName]) {
|
||||
currentLayer = layers[layerName];
|
||||
currentLayer.addTo(map);
|
||||
}
|
||||
document.querySelectorAll('.map-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelector(`.map-btn[data-layer="${layerName}"]`)?.classList.add('active');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var mapContainer = document.getElementById('weather-map');
|
||||
if (!mapContainer) return;
|
||||
|
||||
map = L.map('weather-map').setView([35.2271, -80.8431], 8);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
layers = {
|
||||
temp: L.tileLayer('https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }),
|
||||
precip: L.tileLayer('https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }),
|
||||
clouds: L.tileLayer('https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }),
|
||||
wind: L.tileLayer('https://tile.openweathermap.org/map/wind_speed/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 })
|
||||
};
|
||||
|
||||
L.marker([35.2271, -80.8431]).addTo(map).bindPopup('Charlotte, NC').openPopup();
|
||||
|
||||
showLayer('temp');
|
||||
});
|
||||
69
bookings.php
69
bookings.php
@ -60,7 +60,7 @@ $page_title = "Bookings";
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
|
||||
<!-- Fonts -->
|
||||
@ -70,49 +70,40 @@ $page_title = "Bookings";
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header text-white">
|
||||
<div class="container-fluid">
|
||||
<h1 class="display-6 m-0"><?= htmlspecialchars($project_name) ?></h1>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="index.php">Dashboard</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="customers.php">Customers</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="bookings.php">Bookings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="ai-call-logs.php">AI Call Logs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<main class="container-fluid mt-4">
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> <?= htmlspecialchars($error) ?>
|
||||
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0"><i class="bi bi-calendar-check-fill me-2"></i>All Bookings</h5>
|
||||
<h5 class="m-0"><i class="fas fa-calendar-check me-2"></i>All Bookings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="" class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<select name="service_type" class="form-select">
|
||||
<select name="service_type" class="form-select bg-dark text-white">
|
||||
<option value="">All Services</option>
|
||||
<?php foreach ($service_types as $type): ?>
|
||||
<option value="<?= htmlspecialchars($type) ?>" <?= (isset($_GET['service_type']) && $_GET['service_type'] == $type) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($type)) ?></option>
|
||||
@ -120,7 +111,7 @@ $page_title = "Bookings";
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="urgency_level" class="form-select">
|
||||
<select name="urgency_level" class="form-select bg-dark text-white">
|
||||
<option value="">All Urgencies</option>
|
||||
<?php foreach ($urgency_levels as $level): ?>
|
||||
<option value="<?= htmlspecialchars($level) ?>" <?= (isset($_GET['urgency_level']) && $_GET['urgency_level'] == $level) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($level)) ?></option>
|
||||
@ -128,7 +119,7 @@ $page_title = "Bookings";
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="status" class="form-select">
|
||||
<select name="status" class="form-select bg-dark text-white">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statuses as $status): ?>
|
||||
<option value="<?= htmlspecialchars($status) ?>" <?= (isset($_GET['status']) && $_GET['status'] == $status) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($status)) ?></option>
|
||||
@ -143,7 +134,7 @@ $page_title = "Bookings";
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Customer</th>
|
||||
@ -177,13 +168,9 @@ $page_title = "Bookings";
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer class="container-fluid text-center text-muted py-3 mt-4">
|
||||
<small>Powered by Flatlogic</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
125
calendar.php
Normal file
125
calendar.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Fetch calendar events
|
||||
$stmt = $pdo->query("SELECT title, start_date as start, end_date as end, event_type FROM calendar_events");
|
||||
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$calendar_events_json = json_encode($events);
|
||||
|
||||
// Stat cards data
|
||||
$stmt_upcoming = $pdo->prepare("SELECT COUNT(*) FROM calendar_events WHERE start_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)");
|
||||
$stmt_upcoming->execute();
|
||||
$upcoming_appointments_count = $stmt_upcoming->fetchColumn();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$project_name = "HVAC Command Center";
|
||||
$page_title = "Calendar";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title><?= htmlspecialchars($page_title) ?> - <?= htmlspecialchars($project_name) ?></title>
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
|
||||
<!-- FullCalendar -->
|
||||
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js'></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<?php else: ?>
|
||||
<!-- Stat Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Upcoming Appointments (Next 7 Days)</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars($upcoming_appointments_count) ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Technician Workload</h6>
|
||||
<p class="text-muted small">Workload summary coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
events: <?= $calendar_events_json ?? '[]' ?>,
|
||||
eventDidMount: function(info) {
|
||||
// Apply custom styles for dark theme
|
||||
info.el.style.borderColor = '#4dc9ff';
|
||||
if (info.event.extendedProps.event_type === 'booking') {
|
||||
info.el.style.backgroundColor = '#0d6efd';
|
||||
} else if (info.event.extendedProps.event_type === 'maintenance') {
|
||||
info.el.style.backgroundColor = '#198754';
|
||||
} else {
|
||||
info.el.style.backgroundColor = '#6c757d';
|
||||
}
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
202
call-tracking.php
Normal file
202
call-tracking.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Timeframe filters
|
||||
$today = date('Y-m-d');
|
||||
$this_week_start = date('Y-m-d', strtotime('monday this week'));
|
||||
$this_month_start = date('Y-m-01');
|
||||
|
||||
// Total calls
|
||||
$stmt_calls_today = $pdo->prepare("SELECT COUNT(*) as total FROM call_tracking WHERE DATE(call_start_time) = ?");
|
||||
$stmt_calls_today->execute([$today]);
|
||||
$total_calls_today = $stmt_calls_today->fetch()['total'];
|
||||
|
||||
$stmt_calls_week = $pdo->prepare("SELECT COUNT(*) as total FROM call_tracking WHERE call_start_time >= ?");
|
||||
$stmt_calls_week->execute([$this_week_start]);
|
||||
$total_calls_week = $stmt_calls_week->fetch()['total'];
|
||||
|
||||
$stmt_calls_month = $pdo->prepare("SELECT COUNT(*) as total FROM call_tracking WHERE call_start_time >= ?");
|
||||
$stmt_calls_month->execute([$this_month_start]);
|
||||
$total_calls_month = $stmt_calls_month->fetch()['total'];
|
||||
|
||||
// Calls by source (for pie chart)
|
||||
$stmt_calls_by_source = $pdo->query("
|
||||
SELECT traffic_source, COUNT(*) as count
|
||||
FROM call_tracking
|
||||
GROUP BY traffic_source
|
||||
ORDER BY count DESC
|
||||
");
|
||||
$calls_by_source = $stmt_calls_by_source->fetchAll();
|
||||
$chart_labels_source = json_encode(array_column($calls_by_source, 'traffic_source'));
|
||||
$chart_data_source = json_encode(array_column($calls_by_source, 'count'));
|
||||
|
||||
// AI Rescue Rate
|
||||
$stmt_ai_rescue = $pdo->query("
|
||||
SELECT
|
||||
(SUM(CASE WHEN was_ai_rescue = 1 THEN 1 ELSE 0 END) / COUNT(*)) * 100 as rescue_rate
|
||||
FROM call_tracking
|
||||
WHERE call_status = 'missed' OR answered_by != 'human'
|
||||
");
|
||||
$ai_rescue_rate = $stmt_ai_rescue->fetchColumn() ?? 0;
|
||||
|
||||
// Recent calls
|
||||
$stmt_recent_calls = $pdo->query('SELECT * FROM call_tracking ORDER BY call_start_time DESC LIMIT 10');
|
||||
$recent_calls = $stmt_recent_calls->fetchAll();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$project_name = "HVAC Command Center";
|
||||
$page_title = "Call Tracking";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?></div>
|
||||
<?php else: ?>
|
||||
<!-- Stat Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-muted">Calls Today</h6>
|
||||
<h4 class="fw-bold mb-0"><?= $total_calls_today ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-muted">This Week</h6>
|
||||
<h4 class="fw-bold mb-0"><?= $total_calls_week ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-muted">This Month</h6>
|
||||
<h4 class="fw-bold mb-0"><?= $total_calls_month ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100 bg-success text-white">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-white">AI Rescue Rate</h6>
|
||||
<h4 class="fw-bold mb-0"><?= number_format($ai_rescue_rate, 1) ?>%</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Calls by Source</h5></div>
|
||||
<div class="card-body"><canvas id="callsBySourceChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Calls Table -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Recent Calls</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr><th>Caller</th><th>Source</th><th>Status</th><th>Answered By</th><th>Duration</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent_calls as $call): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($call['caller_name'] ?? $call['caller_number']) ?></td>
|
||||
<td><span class="badge bg-info"><?= htmlspecialchars(ucwords(str_replace('_', ' ', $call['traffic_source']))) ?></span></td>
|
||||
<td><?= htmlspecialchars(ucfirst($call['call_status'])) ?></td>
|
||||
<td><?= htmlspecialchars(ucwords(str_replace('_', ' ', $call['answered_by']))) ?></td>
|
||||
<td><?= $call['call_duration_seconds'] ? gmdate("i:s", $call['call_duration_seconds']) : 'N/A' ?></td>
|
||||
<td><?= date("M d, Y h:i A", strtotime($call['call_start_time'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recent_calls)): ?>
|
||||
<tr><td colspan="6" class="text-center text-muted">No calls recorded yet.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
Chart.defaults.color = '#aab0bb';
|
||||
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
|
||||
|
||||
// Chart for Calls by Source
|
||||
const ctxSource = document.getElementById('callsBySourceChart');
|
||||
if (ctxSource) {
|
||||
new Chart(ctxSource, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: <?= $chart_labels_source ?>,
|
||||
datasets: [{
|
||||
label: 'Calls',
|
||||
data: <?= $chart_data_source ?>,
|
||||
backgroundColor: chartBackgroundColor,
|
||||
borderColor: '#2d3446',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
225
chat-logs.php
Normal file
225
chat-logs.php
Normal file
@ -0,0 +1,225 @@
|
||||
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Timeframe for stats
|
||||
$today_start = date('Y-m-d 00:00:00');
|
||||
$this_week_start = date('Y-m-d 00:00:00', strtotime('monday this week'));
|
||||
$this_month_start = date('Y-m-01 00:00:00');
|
||||
|
||||
// Total chats
|
||||
$stmt_chats_today = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?");
|
||||
$stmt_chats_today->execute([$today_start]);
|
||||
$chats_today = $stmt_chats_today->fetchColumn();
|
||||
|
||||
$stmt_chats_week = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?");
|
||||
$stmt_chats_week->execute([$this_week_start]);
|
||||
$chats_this_week = $stmt_chats_week->fetchColumn();
|
||||
|
||||
$stmt_chats_month = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?");
|
||||
$stmt_chats_month->execute([$this_month_start]);
|
||||
$chats_this_month = $stmt_chats_month->fetchColumn();
|
||||
|
||||
// Conversion Rate
|
||||
$stmt_total_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs");
|
||||
$total_chats = $stmt_total_chats->fetchColumn();
|
||||
$stmt_converted_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs WHERE was_converted = 1");
|
||||
$converted_chats = $stmt_converted_chats->fetchColumn();
|
||||
$conversion_rate = ($total_chats > 0) ? ($converted_chats / $total_chats) * 100 : 0;
|
||||
|
||||
// Average Duration
|
||||
$stmt_avg_duration = $pdo->query("SELECT AVG(chat_duration_seconds) FROM chat_logs");
|
||||
$avg_duration_seconds = $stmt_avg_duration->fetchColumn();
|
||||
$avg_duration = ($avg_duration_seconds) ? gmdate("i:s", $avg_duration_seconds) : 'N/A';
|
||||
|
||||
// Chat Outcomes (for Pie Chart)
|
||||
$stmt_outcomes = $pdo->query("SELECT chat_outcome, COUNT(*) as count FROM chat_logs GROUP BY chat_outcome");
|
||||
$chat_outcomes = $stmt_outcomes->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Recent Chats
|
||||
$stmt_recent_chats = $pdo->query("SELECT * FROM chat_logs ORDER BY created_at DESC LIMIT 10");
|
||||
$recent_chats = $stmt_recent_chats->fetchAll();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$project_name = "HVAC Command Center";
|
||||
$page_title = "Chat Logs";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-comments me-2"></i><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Stat Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Chats Today</h5>
|
||||
<p class="card-text display-4"><?= $chats_today ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">This Week</h5>
|
||||
<p class="card-text display-4"><?= $chats_this_week ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Conversion Rate</h5>
|
||||
<p class="card-text display-4"><?= number_format($conversion_rate, 1) ?>%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Avg. Duration</h5>
|
||||
<p class="card-text display-4"><?= $avg_duration ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Chat Outcomes Pie Chart -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Chat Outcomes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="chatOutcomesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Chats Table -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Recent Chats</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Customer</th>
|
||||
<th>Duration</th>
|
||||
<th>Outcome</th>
|
||||
<th>Converted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($recent_chats)): ?>
|
||||
<tr><td colspan="5" class="text-center">No chats found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recent_chats as $chat): ?>
|
||||
<tr>
|
||||
<td><?= date('M d, Y H:i', strtotime($chat['created_at'])) ?></td>
|
||||
<td><?= htmlspecialchars($chat['customer_name']) ?></td>
|
||||
<td><?= gmdate("i:s", $chat['chat_duration_seconds']) ?></td>
|
||||
<td><span class="badge bg-secondary"><?= htmlspecialchars($chat['chat_outcome']) ?></span></td>
|
||||
<td>
|
||||
<?php if ($chat['was_converted']): ?>
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">No</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Chart.js: Chat Outcomes Pie Chart
|
||||
const outcomeLabels = <?= json_encode(array_keys($chat_outcomes)) ?>;
|
||||
const outcomeData = <?= json_encode(array_values($chat_outcomes)) ?>;
|
||||
|
||||
const outcomesChartCtx = document.getElementById('chatOutcomesChart').getContext('2d');
|
||||
new Chart(outcomesChartCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: outcomeLabels,
|
||||
datasets: [{
|
||||
label: 'Chat Outcomes',
|
||||
data: outcomeData,
|
||||
backgroundColor: ['#4dc9ff', '#f672a7', '#ffc107', '#28a745', '#dc3545', '#6c757d'],
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -9,7 +9,6 @@ try {
|
||||
$customers = $stmt_customers->fetchAll();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// For production, you would log this error and show a user-friendly message.
|
||||
$error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
@ -27,7 +26,7 @@ $page_title = "Customers";
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
|
||||
<!-- Fonts -->
|
||||
@ -37,49 +36,40 @@ $page_title = "Customers";
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header text-white">
|
||||
<div class="container-fluid">
|
||||
<h1 class="display-6 m-0"><?= htmlspecialchars($project_name) ?></h1>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="index.php">Dashboard</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="customers.php">Customers</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="bookings.php">Bookings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="ai-call-logs.php">AI Call Logs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
</header>
|
||||
|
||||
<main class="container-fluid mt-4">
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> <?= htmlspecialchars($error) ?>
|
||||
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0"><i class="bi bi-people-fill me-2"></i>All Customers</h5>
|
||||
<h5 class="m-0"><i class="fas fa-users me-2"></i>All Customers</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
@ -109,13 +99,9 @@ $page_title = "Customers";
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer class="container-fluid text-center text-muted py-3 mt-4">
|
||||
<small>Powered by Flatlogic</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -1,46 +1,48 @@
|
||||
-- Adapted from user requirements for MySQL/MariaDB
|
||||
-- Raw reviews storage
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
location_id VARCHAR(100),
|
||||
site VARCHAR(50),
|
||||
review_id VARCHAR(255),
|
||||
reviewer_name VARCHAR(255),
|
||||
rating DECIMAL(2,1),
|
||||
review_text TEXT,
|
||||
review_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_review (site, review_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `customers` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`first_name` VARCHAR(100),
|
||||
`last_name` VARCHAR(100),
|
||||
`email` VARCHAR(255) UNIQUE,
|
||||
`phone` VARCHAR(20),
|
||||
`phone_normalized` VARCHAR(15),
|
||||
`service_address` TEXT,
|
||||
`city` VARCHAR(100),
|
||||
`state` VARCHAR(50),
|
||||
`zip_code` VARCHAR(20),
|
||||
`lifetime_value` DECIMAL(10, 2) DEFAULT 0,
|
||||
`total_bookings` INT DEFAULT 0,
|
||||
`total_quotes` INT DEFAULT 0,
|
||||
`acquisition_source` ENUM('google_ads', 'google_lsa', 'organic', 'referral', 'facebook', 'yelp', 'direct', 'other'),
|
||||
`acquisition_campaign` VARCHAR(255),
|
||||
`first_contact_date` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
-- Daily aggregates for fast dashboard queries
|
||||
CREATE TABLE IF NOT EXISTS review_aggregates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
location_id VARCHAR(100),
|
||||
site VARCHAR(50),
|
||||
date DATE,
|
||||
total_reviews INT,
|
||||
avg_rating DECIMAL(3,2),
|
||||
five_star INT DEFAULT 0,
|
||||
four_star INT DEFAULT 0,
|
||||
three_star INT DEFAULT 0,
|
||||
two_star INT DEFAULT 0,
|
||||
one_star INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_aggregate (location_id, site, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `bookings` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`record_type` ENUM('appointment', 'quote_request') NOT NULL,
|
||||
`customer_name` VARCHAR(255),
|
||||
`customer_phone` VARCHAR(20),
|
||||
`customer_email` VARCHAR(255),
|
||||
`service_address` TEXT,
|
||||
`service_category` ENUM('repair', 'maintenance', 'installation', 'inspection', 'emergency'),
|
||||
`service_type` VARCHAR(100),
|
||||
`system_type` ENUM('central_air', 'heat_pump', 'furnace', 'mini_split', 'boiler', 'other'),
|
||||
`urgency_level` ENUM('routine', 'urgent', 'emergency'),
|
||||
`issue_description` TEXT,
|
||||
`appointment_date` DATE,
|
||||
`appointment_time` VARCHAR(20),
|
||||
`status` ENUM('new', 'confirmed', 'dispatched', 'in_progress', 'completed', 'cancelled', 'no_show') NOT NULL DEFAULT 'new',
|
||||
`estimated_revenue` DECIMAL(10, 2),
|
||||
`actual_revenue` DECIMAL(10, 2),
|
||||
`booked_by` ENUM('ai_agent', 'human_agent', 'online', 'walk_in'),
|
||||
`customer_id` INT,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
-- Latest snapshot for quick dashboard display
|
||||
CREATE TABLE IF NOT EXISTS review_snapshot (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
location_id VARCHAR(100),
|
||||
total_reviews INT,
|
||||
avg_rating DECIMAL(3,2),
|
||||
reviews_this_week INT,
|
||||
reviews_this_month INT,
|
||||
google_reviews INT,
|
||||
google_avg DECIMAL(3,2),
|
||||
yelp_reviews INT,
|
||||
yelp_avg DECIMAL(3,2),
|
||||
facebook_reviews INT,
|
||||
facebook_avg DECIMAL(3,2),
|
||||
last_synced TIMESTAMP,
|
||||
UNIQUE KEY unique_snapshot (location_id)
|
||||
);
|
||||
|
||||
362
index.php
362
index.php
@ -20,14 +20,11 @@ try {
|
||||
$stmt_recent_bookings = $pdo->query('SELECT * FROM bookings ORDER BY created_at DESC LIMIT 5');
|
||||
$recent_bookings = $stmt_recent_bookings->fetchAll();
|
||||
|
||||
// --- API Key Management ---
|
||||
|
||||
// Handle API key generation
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_api_key'])) {
|
||||
$new_key = 'hvac_' . bin2hex(random_bytes(16));
|
||||
$stmt_insert_key = $pdo->prepare("INSERT INTO api_keys (api_key) VALUES (?)");
|
||||
$stmt_insert_key->execute([$new_key]);
|
||||
// Redirect to avoid form resubmission
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit;
|
||||
}
|
||||
@ -36,13 +33,31 @@ try {
|
||||
$stmt_api_keys = $pdo->query('SELECT * FROM api_keys ORDER BY created_at DESC');
|
||||
$api_keys = $stmt_api_keys->fetchAll();
|
||||
|
||||
// Fetch latest weather data
|
||||
$default_zip_code = '28202';
|
||||
$stmt_weather = $pdo->prepare('SELECT * FROM weather WHERE zip_code = ? ORDER BY observation_time DESC LIMIT 1');
|
||||
$stmt_weather->execute([$default_zip_code]);
|
||||
$current_weather = $stmt_weather->fetch();
|
||||
|
||||
// Fetch chat stats
|
||||
$today_start = date('Y-m-d 00:00:00');
|
||||
$stmt_chats_today = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE chat_start_time >= ?");
|
||||
$stmt_chats_today->execute([$today_start]);
|
||||
$chats_today_count = $stmt_chats_today->fetchColumn();
|
||||
|
||||
$stmt_total_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs");
|
||||
$total_chats = $stmt_total_chats->fetchColumn();
|
||||
$stmt_converted_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs WHERE was_converted = 1");
|
||||
$converted_chats = $stmt_converted_chats->fetchColumn();
|
||||
$chat_conversion_rate = ($total_chats > 0) ? ($converted_chats / $total_chats) * 100 : 0;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// For production, you would log this error and show a user-friendly message.
|
||||
$error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$project_name = "HVAC Command Center";
|
||||
$project_description = "Central dashboard for managing your HVAC business operations.";
|
||||
$page_title = "Dashboard";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@ -51,7 +66,7 @@ $project_description = "Central dashboard for managing your HVAC business operat
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title><?= htmlspecialchars($project_name) ?></title>
|
||||
<title><?= htmlspecialchars($page_title . ' | ' . $project_name) ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($project_description) ?>">
|
||||
|
||||
<!-- Open Graph / Twitter -->
|
||||
@ -62,8 +77,9 @@ $project_description = "Central dashboard for managing your HVAC business operat
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@ -71,167 +87,233 @@ $project_description = "Central dashboard for managing your HVAC business operat
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header text-white">
|
||||
<div class="container-fluid">
|
||||
<h1 class="display-6 m-0"><?= htmlspecialchars($project_name) ?></h1>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link active" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="index.php">Dashboard</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
<button class="btn btn-primary d-md-none" type="button" data-bs-toggle="collapse" data-bs-target=".sidebar">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="customers.php">Customers</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="bookings.php">Bookings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="ai-call-logs.php">AI Call Logs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container-fluid mt-4">
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> <?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i><?= htmlspecialchars($error) ?></div>
|
||||
<?php else: ?>
|
||||
<!-- Stat Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted">Total Customers</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_customers) ?></h4>
|
||||
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 5% this month</small>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-users"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted">Total Bookings</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_bookings) ?></h4>
|
||||
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 12% this month</small>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-calendar-check"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted">Chats Today</h6>
|
||||
<h4 class="fw-bold mb-0"><?= htmlspecialchars($chats_today_count) ?></h4>
|
||||
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> <?= number_format($chat_conversion_rate, 0) ?>% conv.</small>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-comments"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted">Completed Revenue</h6>
|
||||
<h4 class="fw-bold mb-0">$<?= number_format($total_revenue, 2) ?></h4>
|
||||
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 8% this month</small>
|
||||
</div>
|
||||
<div class="icon"><i class="fas fa-dollar-sign"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<i class="bi bi-people-fill display-4 text-primary me-3"></i>
|
||||
<div>
|
||||
<h5 class="card-title">Total Customers</h5>
|
||||
<p class="card-text fs-2 fw-bold"><?= htmlspecialchars($total_customers) ?></p>
|
||||
<a href="customers.php" class="btn btn-outline-primary btn-sm mt-2">View All</a>
|
||||
<div class="col-lg-5">
|
||||
<!-- Weather Widget -->
|
||||
<div class="card mb-4" id="weather-widget">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="card-title mb-0"><i class="fas fa-cloud-sun me-2"></i>Current Weather</h5>
|
||||
<button id="refresh-weather-btn" class="btn btn-sm btn-outline-info"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
<div id="weather-content">
|
||||
<?php if ($current_weather): ?>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-0"><?= htmlspecialchars($current_weather['location_name']) ?></h6>
|
||||
<p class="text-muted mb-0 small">Last updated: <span id="weather-last-updated"><?= htmlspecialchars(date("g:i A", strtotime($current_weather['observation_time']))) ?></span></p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<img id="weather-icon" src="https://openweathermap.org/img/wn/<?= htmlspecialchars($current_weather['weather_icon']) ?>@2x.png" alt="Weather icon" style="width: 50px; height: 50px;">
|
||||
<span id="weather-temp" class="fs-4 fw-bold"><?= round($current_weather['temperature_f']) ?>°F</span>
|
||||
<p id="weather-feels-like" class="mb-0 text-muted small">Feels like <?= round($current_weather['feels_like_f']) ?>°F</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="weather-desc" class="mb-0 fst-italic text-center mt-2"><?= htmlspecialchars(ucwords($current_weather['weather_description'])) ?></p>
|
||||
<div id="weather-alerts"></div>
|
||||
<?php else: ?>
|
||||
<p id="weather-placeholder" class="text-muted text-center mb-0">Weather data not available. Click Refresh.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div id="weather-loading" class="text-center" style="display: none;">
|
||||
<div class="spinner-border text-info" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
<p class="mt-2">Fetching latest weather...</p>
|
||||
</div>
|
||||
<div id="weather-error" class="alert alert-warning mt-2" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weather Map Widget -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="card-title mb-0"><i class="fas fa-map-marked-alt me-2"></i>Service Area Weather Map</h5></div>
|
||||
<div class="card-body p-0"><div id="weather-map"></div></div>
|
||||
<div class="card-footer text-center map-buttons">
|
||||
<button class="map-btn active" data-layer="temp" onclick="showLayer('temp')">Temp</button>
|
||||
<button class="map-btn" data-layer="precip" onclick="showLayer('precip')">Precip</button>
|
||||
<button class="map-btn" data-layer="clouds" onclick="showLayer('clouds')">Clouds</button>
|
||||
<button class="map-btn" data-layer="wind" onclick="showLayer('wind')">Wind</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<i class="bi bi-calendar-check-fill display-4 text-success me-3"></i>
|
||||
<div>
|
||||
<h5 class="card-title">Total Bookings</h5>
|
||||
<p class="card-text fs-2 fw-bold"><?= htmlspecialchars($total_bookings) ?></p>
|
||||
<a href="bookings.php" class="btn btn-outline-success btn-sm mt-2">View All</a>
|
||||
<div class="col-lg-7">
|
||||
<!-- Recent Bookings Table -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="m-0"><i class="fas fa-clock-rotate-left me-2"></i>Recent Bookings</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Date</th><th>Customer</th><th>Service</th><th>Urgency</th><th>Status</th><th class="text-end">Est. Revenue</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent_bookings as $booking): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars(date("M d, Y", strtotime($booking['appointment_date']))) ?></td>
|
||||
<td><?= htmlspecialchars($booking['customer_name']) ?></td>
|
||||
<td><?= htmlspecialchars($booking['service_type']) ?></td>
|
||||
<td><span class="badge bg-<?= strtolower(htmlspecialchars($booking['urgency_level'])) == 'emergency' ? 'danger' : (strtolower(htmlspecialchars($booking['urgency_level'])) == 'urgent' ? 'warning' : 'secondary') ?>"><?= htmlspecialchars(ucfirst($booking['urgency_level'])) ?></span></td>
|
||||
<td><span class="badge bg-light text-dark border"><?= htmlspecialchars(ucfirst($booking['status'])) ?></span></td>
|
||||
<td class="text-end fw-bold">$<?= number_format($booking['estimated_revenue'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recent_bookings)): ?>
|
||||
<tr><td colspan="6" class="text-center text-muted">No recent bookings found.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<i class="bi bi-cash-stack display-4 text-info me-3"></i>
|
||||
<div>
|
||||
<h5 class="card-title">Completed Revenue</h5>
|
||||
<p class="card-text fs-2 fw-bold">$<?= number_format($total_revenue, 2) ?></p>
|
||||
<!-- API Key Management -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="m-0"><i class="fas fa-key me-2"></i>API Keys</h5>
|
||||
<form method="POST" action=""><button type="submit" name="generate_api_key" class="btn btn-primary btn-sm">Generate New Key</button></form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>API Key</th><th>Status</th><th>Created On</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($api_keys as $key): ?>
|
||||
<tr>
|
||||
<td><input type="text" readonly class="form-control-plaintext" value="<?= htmlspecialchars($key['api_key']) ?>"></td>
|
||||
<td><span class="badge bg-<?= $key['is_active'] ? 'success' : 'danger' ?>"><?= $key['is_active'] ? 'Active' : 'Inactive' ?></span></td>
|
||||
<td><?= htmlspecialchars(date("M d, Y", strtotime($key['created_at']))) ?></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($api_keys)): ?>
|
||||
<tr><td colspan="4" class="text-center text-muted">No API keys found.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Management -->
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="m-0"><i class="bi bi-key-fill me-2"></i>API Keys</h5>
|
||||
<form method="POST" action="">
|
||||
<button type="submit" name="generate_api_key" class="btn btn-primary btn-sm">Generate New Key</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>API Key</th>
|
||||
<th>Status</th>
|
||||
<th>Created On</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($api_keys as $key): ?>
|
||||
<tr>
|
||||
<td><input type="text" readonly class="form-control-plaintext" value="<?= htmlspecialchars($key['api_key']) ?>"></td>
|
||||
<td><span class="badge bg-<?= $key['is_active'] ? 'success' : 'danger' ?>"><?= $key['is_active'] ? 'Active' : 'Inactive' ?></span></td>
|
||||
<td><?= htmlspecialchars(date("M d, Y", strtotime($key['created_at']))) ?></td>
|
||||
<td><!-- Action buttons here --></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($api_keys)): ?>
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">No API keys found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="card-title mb-0"><i class="fas fa-chart-line me-2"></i>Booking Trends</h5></div>
|
||||
<div class="card-body"><canvas id="booking-trends-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Bookings Table -->
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="m-0"><i class="bi bi-clock-history me-2"></i>Recent Bookings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Customer</th>
|
||||
<th>Service</th>
|
||||
<th>Urgency</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Est. Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent_bookings as $booking): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars(date("M d, Y", strtotime($booking['appointment_date']))) ?></td>
|
||||
<td><?= htmlspecialchars($booking['customer_name']) ?></td>
|
||||
<td><?= htmlspecialchars($booking['service_type']) ?></td>
|
||||
<td><span class="badge bg-<?= strtolower(htmlspecialchars($booking['urgency_level'])) == 'emergency' ? 'danger' : (strtolower(htmlspecialchars($booking['urgency_level'])) == 'urgent' ? 'warning' : 'secondary') ?>"><?= htmlspecialchars(ucfirst($booking['urgency_level'])) ?></span></td>
|
||||
<td><span class="badge bg-light text-dark border"><?= htmlspecialchars(ucfirst($booking['status'])) ?></span></td>
|
||||
<td class="text-end fw-bold">$<?= number_format($booking['estimated_revenue'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recent_bookings)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No recent bookings found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer class="container-fluid text-center text-muted py-3 mt-4">
|
||||
<small>Powered by Flatlogic</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
<script src="assets/js/map.js?v=<?php echo time(); ?>"></script>
|
||||
<script>
|
||||
// Basic Chart.js Example
|
||||
const ctx = document.getElementById('booking-trends-chart').getContext('2d');
|
||||
const bookingChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
|
||||
datasets: [{
|
||||
label: 'Bookings',
|
||||
data: [65, 59, 80, 81, 56, 55, 40],
|
||||
fill: true,
|
||||
backgroundColor: 'rgba(77, 201, 255, 0.2)',
|
||||
borderColor: '#4dc9ff',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: 'rgba(255,255,255,0.1)' } },
|
||||
y: { grid: { color: 'rgba(255,255,255,0.1)' } }
|
||||
},
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
238
reviews.php
Normal file
238
reviews.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Notice: This page now uses hardcoded data and is set up for BrightLocal integration.
|
||||
// The previous database queries have been removed.
|
||||
|
||||
// Placeholder data as per the request
|
||||
$avg_rating = 4.93;
|
||||
$total_reviews = 504;
|
||||
|
||||
// Placeholder for chart data - this will be populated by JavaScript
|
||||
$chart_labels_rating = json_encode([]);
|
||||
$chart_data_rating = json_encode([]);
|
||||
$chart_labels_platform = json_encode([]);
|
||||
$chart_data_platform = json_encode([]);
|
||||
|
||||
// Placeholder for recent reviews - this will be populated by JavaScript
|
||||
$recent_reviews = [];
|
||||
|
||||
$project_name = "HVAC Command Center";
|
||||
$page_title = "BrightLocal Reviews";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><?= htmlspecialchars($page_title) ?></h1>
|
||||
<button id="sync-reviews-btn" class="btn btn-primary"><i class="fas fa-sync-alt me-2"></i>Sync Reviews</button>
|
||||
</header>
|
||||
|
||||
<div id="loading-spinner" class="text-center" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p>Fetching latest reviews from BrightLocal...</p>
|
||||
</div>
|
||||
|
||||
<div id="reviews-content">
|
||||
<!-- Stat Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-muted">Average Rating</h6>
|
||||
<h4 id="avg-rating" class="fw-bold mb-0 text-warning"><?= number_format($avg_rating, 2) ?> <i class="fas fa-star"></i></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="text-muted">Total Reviews</h6>
|
||||
<h4 id="total-reviews" class="fw-bold mb-0"><?= $total_reviews ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Rating Breakdown</h5></div>
|
||||
<div class="card-body"><canvas id="ratingDistributionChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Reviews by Source</h5></div>
|
||||
<div class="card-body"><canvas id="platformDistributionChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Reviews Table -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="m-0">Recent Reviews</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead><tr><th>Reviewer</th><th>Source</th><th>Rating</th><th>Date</th><th>Comment</th></tr></thead>
|
||||
<tbody id="recent-reviews-body">
|
||||
<tr><td colspan="5" class="text-center text-muted">Click "Sync Reviews" to load data.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const syncButton = document.getElementById('sync-reviews-btn');
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
const reviewsContent = document.getElementById('reviews-content');
|
||||
|
||||
let ratingChart, platformChart;
|
||||
|
||||
syncButton.addEventListener('click', function() {
|
||||
loadingSpinner.style.display = 'block';
|
||||
reviewsContent.style.display = 'none';
|
||||
|
||||
fetch('/api/brightlocal-sync.php')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success && result.data.success) {
|
||||
const reviewsData = result.data.data;
|
||||
updateDashboard(reviewsData);
|
||||
} else {
|
||||
alert('Error fetching reviews: ' + (result.error || result.data.error));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while fetching data.');
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinner.style.display = 'none';
|
||||
reviewsContent.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
function updateDashboard(data) {
|
||||
// Update stats
|
||||
document.getElementById('avg-rating').innerHTML = `${parseFloat(data['summary']['average-rating']).toFixed(2)} <i class="fas fa-star"></i>`;
|
||||
document.getElementById('total-reviews').textContent = data['summary']['total-reviews'];
|
||||
|
||||
// Update charts
|
||||
updateRatingChart(data['summary']['rating-breakdown']);
|
||||
updatePlatformChart(data['summary']['source-breakdown']);
|
||||
|
||||
// Update recent reviews table
|
||||
updateRecentReviews(data['reviews']);
|
||||
}
|
||||
|
||||
function updateRatingChart(ratingData) {
|
||||
const ctx = document.getElementById('ratingDistributionChart').getContext('2d');
|
||||
const labels = Object.keys(ratingData).sort((a,b) => b-a);
|
||||
const chartData = labels.map(label => ratingData[label]);
|
||||
|
||||
if(ratingChart) ratingChart.destroy();
|
||||
ratingChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels.map(l => l + ' Star'),
|
||||
datasets: [{
|
||||
label: 'Number of Reviews',
|
||||
data: chartData,
|
||||
backgroundColor: 'rgba(77, 201, 255, 0.5)',
|
||||
borderColor: '#4dc9ff',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
scales: { x: { beginAtZero: true } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlatformChart(platformData) {
|
||||
const ctx = document.getElementById('platformDistributionChart').getContext('2d');
|
||||
const labels = Object.keys(platformData);
|
||||
const chartData = Object.values(platformData).map(p => p.count);
|
||||
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
|
||||
|
||||
if(platformChart) platformChart.destroy();
|
||||
platformChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: chartData,
|
||||
backgroundColor: chartBackgroundColor,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom'} }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRecentReviews(reviews) {
|
||||
const tbody = document.getElementById('recent-reviews-body');
|
||||
tbody.innerHTML = '';
|
||||
if(reviews.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No reviews found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
reviews.slice(0, 10).forEach(review => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td>${review.customer_name || 'Anonymous'}</td>
|
||||
<td>${review.source}</td>
|
||||
<td><span class="text-warning">${ '★'.repeat(review.rating) + '☆'.repeat(5 - review.rating) }</span></td>
|
||||
<td>${new Date(review.date).toLocaleDateString()}</td>
|
||||
<td class="text-truncate" style="max-width: 300px;">${review.comment}</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user