Autosave: 20260707-015607
This commit is contained in:
parent
e282778646
commit
2cde551bb9
@ -30,9 +30,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_visibility'])) {
|
||||
exit();
|
||||
}
|
||||
|
||||
// Guardar Shalom API Key (para que tracking funcione sin editar .env)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_shalom_api_key'])) {
|
||||
$shalomApiKey = trim($_POST['shalom_api_key'] ?? '');
|
||||
if ($shalomApiKey === '') {
|
||||
$_SESSION['error_message'] = 'Debes ingresar la SHALOM_API_KEY.';
|
||||
} else {
|
||||
$stmt = $conn->prepare("INSERT INTO configuracion (clave, valor) VALUES ('SHALOM_API_KEY', :valor) ON DUPLICATE KEY UPDATE valor = :valor");
|
||||
$stmt->bindParam(':valor', $shalomApiKey);
|
||||
if ($stmt->execute()) {
|
||||
$_SESSION['success_message'] = 'Shalom API key guardada correctamente.';
|
||||
} else {
|
||||
$_SESSION['error_message'] = 'Error al guardar la Shalom API key.';
|
||||
}
|
||||
}
|
||||
header("Location: configuracion.php");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Sincronizar sedes de Shalom
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['sync_shalom'])) {
|
||||
$apiKey = 'sk_mlq1j3na_4a676ewvaop';
|
||||
$apiKey = getenv('SHALOM_API_KEY');
|
||||
|
||||
if (!$apiKey) {
|
||||
$stmtKey = $conn->prepare("SELECT valor FROM configuracion WHERE clave = 'SHALOM_API_KEY' LIMIT 1");
|
||||
$stmtKey->execute();
|
||||
$apiKey = $stmtKey->fetchColumn();
|
||||
}
|
||||
|
||||
if (!$apiKey) {
|
||||
$_SESSION['error_message'] = 'Falta SHALOM_API_KEY. Configúrala en Configuración General (Administrador) antes de sincronizar sedes.';
|
||||
header("Location: configuracion.php");
|
||||
exit();
|
||||
}
|
||||
$url = "https://shalom-api.lat/api/listar";
|
||||
|
||||
$ch = curl_init();
|
||||
@ -127,6 +157,19 @@ if (isset($_SESSION['error_message'])) {
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Conéctate directamente con la API de Shalom para actualizar el listado de agencias disponibles en el sistema.</p>
|
||||
|
||||
<form action="configuracion.php" method="post" class="mb-4">
|
||||
<input type="hidden" name="save_shalom_api_key" value="1">
|
||||
<div class="mb-2">
|
||||
<label for="shalom_api_key" class="form-label">Shalom API Key</label>
|
||||
<input type="password" name="shalom_api_key" id="shalom_api_key" class="form-control" placeholder="Pega la clave aquí" autocomplete="off" required>
|
||||
<small class="form-text text-muted">Se usará para el tracking y la sincronización de sedes. (No se muestra la clave actual por seguridad.)</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-key"></i> Guardar clave
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action="configuracion.php" method="post">
|
||||
<input type="hidden" name="sync_shalom" value="1">
|
||||
<button type="submit" class="btn btn-info">
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- Agrega la columna de observación para Producción de Videos
|
||||
ALTER TABLE marketing_videos
|
||||
ADD COLUMN observacion TEXT NULL AFTER estado;
|
||||
36
includes/marketing_bootstrap.php
Normal file
36
includes/marketing_bootstrap.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// Auto-inicializa pequeños ajustes del esquema de Marketing cuando faltan columnas.
|
||||
// Así evitamos errores al abrir la pantalla o guardar datos nuevos.
|
||||
|
||||
function marketing_ensure_schema(PDO $pdo): void
|
||||
{
|
||||
static $attempted = false;
|
||||
if ($attempted) {
|
||||
return;
|
||||
}
|
||||
$attempted = true;
|
||||
|
||||
$checkColumnStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS c '
|
||||
. 'FROM information_schema.columns '
|
||||
. 'WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?'
|
||||
);
|
||||
|
||||
$checkColumnStmt->execute(['marketing_videos', 'observacion']);
|
||||
$row = $checkColumnStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$count = (int)($row['c'] ?? 0);
|
||||
|
||||
if ($count === 0) {
|
||||
$file = __DIR__ . '/../db/migrations/086_add_observacion_to_marketing_videos.sql';
|
||||
if (!is_file($file)) {
|
||||
throw new RuntimeException('No se encontró la migración: ' . basename($file));
|
||||
}
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('No se pudo leer la migración: ' . basename($file));
|
||||
}
|
||||
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,10 @@
|
||||
$pageTitle = "Producción de Video";
|
||||
include 'db/config.php';
|
||||
include 'layout_header.php';
|
||||
require_once 'includes/marketing_bootstrap.php';
|
||||
|
||||
$db = db();
|
||||
marketing_ensure_schema($db);
|
||||
|
||||
// Obtener productos para el select
|
||||
$stmt_products = $db->query("SELECT id, nombre FROM products ORDER BY nombre ASC");
|
||||
@ -16,6 +18,21 @@ $stmt_videos = $db->query("SELECT mv.*, p.nombre as nombre_producto
|
||||
ORDER BY mv.orden ASC, mv.fecha_creacion DESC");
|
||||
$videos = $stmt_videos->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$statusOptions = ['PENDIENTE', 'EN PROCESO', 'TERMINADO', 'OBSERVADO', 'APROBADO', 'PUBLICADO'];
|
||||
|
||||
function marketing_status_badge_class(?string $estado): string
|
||||
{
|
||||
return match ($estado) {
|
||||
'PENDIENTE' => 'bg-warning text-dark',
|
||||
'EN PROCESO' => 'bg-info text-white',
|
||||
'TERMINADO' => 'bg-primary text-white',
|
||||
'OBSERVADO' => 'bg-danger text-white',
|
||||
'APROBADO' => 'bg-success text-white',
|
||||
'PUBLICADO' => 'bg-dark text-white',
|
||||
default => 'bg-secondary text-white',
|
||||
};
|
||||
}
|
||||
|
||||
// Contadores de estado
|
||||
$stats = [
|
||||
'TOTAL' => count($videos),
|
||||
@ -176,10 +193,74 @@ foreach ($videos as $v) {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.status-select {
|
||||
min-width: 160px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.35px;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
padding: 0.55rem 2.25rem 0.55rem 0.9rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.status-select:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.status-select:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(67, 97, 238, 0.16);
|
||||
}
|
||||
.status-select:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: wait;
|
||||
}
|
||||
.status-select[data-status="PENDIENTE"] {
|
||||
background-color: #ffe082;
|
||||
color: #5f4b00;
|
||||
}
|
||||
.status-select[data-status="EN PROCESO"] {
|
||||
background-color: #d8f3ff;
|
||||
color: #0c5460;
|
||||
}
|
||||
.status-select[data-status="TERMINADO"] {
|
||||
background-color: #dce7ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.status-select[data-status="OBSERVADO"] {
|
||||
background-color: #ffe0e6;
|
||||
color: #b42318;
|
||||
}
|
||||
.status-select[data-status="APROBADO"] {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-select[data-status="PUBLICADO"] {
|
||||
background-color: #dfe4ea;
|
||||
color: #111827;
|
||||
}
|
||||
.status-feedback {
|
||||
min-height: 18px;
|
||||
}
|
||||
.editable:hover {
|
||||
background-color: #f8f9fa;
|
||||
cursor: pointer;
|
||||
}
|
||||
.observation-input {
|
||||
min-width: 240px;
|
||||
resize: vertical;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.observation-input:focus {
|
||||
border-color: rgba(67, 97, 238, 0.35);
|
||||
box-shadow: 0 0 0 0.2rem rgba(67, 97, 238, 0.12);
|
||||
}
|
||||
.observation-status {
|
||||
min-height: 18px;
|
||||
}
|
||||
.card-stats {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
@ -256,7 +337,7 @@ foreach ($videos as $v) {
|
||||
<div class="card-body">
|
||||
<div class="stat-icon bg-warning text-white"><i class="fas fa-clock"></i></div>
|
||||
<h6 class="text-muted mb-1 small">Pendientes</h6>
|
||||
<h3 class="mb-0 fw-bold"><?php echo $stats['PENDIENTE']; ?></h3>
|
||||
<h3 class="mb-0 fw-bold" id="status-count-pendiente"><?php echo $stats['PENDIENTE']; ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -265,7 +346,7 @@ foreach ($videos as $v) {
|
||||
<div class="card-body">
|
||||
<div class="stat-icon bg-info text-white"><i class="fas fa-sync"></i></div>
|
||||
<h6 class="text-muted mb-1 small">En Proceso</h6>
|
||||
<h3 class="mb-0 fw-bold"><?php echo $stats['EN PROCESO']; ?></h3>
|
||||
<h3 class="mb-0 fw-bold" id="status-count-en-proceso"><?php echo $stats['EN PROCESO']; ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -274,7 +355,7 @@ foreach ($videos as $v) {
|
||||
<div class="card-body">
|
||||
<div class="stat-icon bg-primary text-white" style="background-color: #4361ee !important;"><i class="fas fa-check-double"></i></div>
|
||||
<h6 class="text-muted mb-1 small">Terminados</h6>
|
||||
<h3 class="mb-0 fw-bold"><?php echo $stats['TERMINADO']; ?></h3>
|
||||
<h3 class="mb-0 fw-bold" id="status-count-terminado"><?php echo $stats['TERMINADO']; ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -283,7 +364,7 @@ foreach ($videos as $v) {
|
||||
<div class="card-body">
|
||||
<div class="stat-icon bg-success text-white" style="background-color: #2ec4b6 !important;"><i class="fas fa-thumbs-up"></i></div>
|
||||
<h6 class="text-muted mb-1 small">Aprobados</h6>
|
||||
<h3 class="mb-0 fw-bold"><?php echo $stats['APROBADO']; ?></h3>
|
||||
<h3 class="mb-0 fw-bold" id="status-count-aprobado"><?php echo $stats['APROBADO']; ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -292,7 +373,8 @@ foreach ($videos as $v) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-0 fw-bold" style="color: #2b2d42;">Producción de Video</h2>
|
||||
<p class="text-muted small">Gestión de activos y flujo de trabajo creativo</p>
|
||||
<p class="text-muted small mb-1">Gestión de activos y flujo de trabajo creativo</p>
|
||||
<p class="text-muted small mb-0">La columna <strong>Observación</strong> se guarda al salir del campo y <strong>Estado</strong> al cambiar la opción.</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bg-white p-1 rounded-3 shadow-sm d-flex border">
|
||||
@ -337,6 +419,7 @@ foreach ($videos as $v) {
|
||||
<th class="text-center"><i class="fas fa-play-circle me-1"></i> Link Video</th>
|
||||
<th class="text-center"><i class="fas fa-globe me-1"></i> Link Landing</th>
|
||||
<th class="text-center"><i class="fas fa-file-image me-1"></i> Link Flyer</th>
|
||||
<th><i class="fas fa-comment-dots me-1"></i> Observación</th>
|
||||
<th class="text-center"><i class="fas fa-tasks me-1"></i> Estado</th>
|
||||
<th class="text-center"><i class="fas fa-cog me-1"></i> Acción</th>
|
||||
</tr>
|
||||
@ -344,7 +427,7 @@ foreach ($videos as $v) {
|
||||
<tbody>
|
||||
<?php if (empty($videos)): ?>
|
||||
<tr>
|
||||
<td colspan="13" class="text-center py-5 text-muted">
|
||||
<td colspan="14" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-video-slash fa-3x mb-3 d-block opacity-25"></i>
|
||||
No hay pedidos registrados.
|
||||
</td>
|
||||
@ -462,21 +545,31 @@ foreach ($videos as $v) {
|
||||
<span class="text-muted">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center editable" data-id="<?php echo $v['id']; ?>" data-field="estado">
|
||||
<span class="badge status-badge <?php
|
||||
echo $v['estado'] == 'PENDIENTE' ? 'bg-warning text-dark' :
|
||||
($v['estado'] == 'EN PROCESO' ? 'bg-info text-white' :
|
||||
($v['estado'] == 'TERMINADO' ? 'bg-primary text-white' :
|
||||
($v['estado'] == 'OBSERVADO' ? 'bg-danger text-white' :
|
||||
($v['estado'] == 'APROBADO' ? 'bg-success text-white' :
|
||||
($v['estado'] == 'PUBLICADO' ? 'bg-dark text-white' : 'bg-secondary text-white')))));
|
||||
?>">
|
||||
<?php echo $v['estado']; ?>
|
||||
</span>
|
||||
<td>
|
||||
<textarea
|
||||
class="form-control form-control-sm observation-input"
|
||||
data-id="<?php echo $v['id']; ?>"
|
||||
rows="3"
|
||||
placeholder="Escribe una observación..."><?php echo htmlspecialchars($v['observacion'] ?? ''); ?></textarea>
|
||||
<div class="small text-muted mt-1 observation-status" id="observation-status-<?php echo $v['id']; ?>"></div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<select
|
||||
class="form-select form-select-sm status-select"
|
||||
data-id="<?php echo $v['id']; ?>"
|
||||
data-status="<?php echo htmlspecialchars($v['estado'] ?? ''); ?>"
|
||||
aria-label="Estado del pedido <?php echo (int)$v['id']; ?>">
|
||||
<?php foreach ($statusOptions as $statusOption): ?>
|
||||
<option value="<?php echo htmlspecialchars($statusOption); ?>" <?php echo ($v['estado'] === $statusOption) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($statusOption); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="small mt-1 status-feedback" id="status-feedback-<?php echo $v['id']; ?>"></div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex justify-content-center gap-1">
|
||||
<button class="btn btn-sm btn-light border shadow-sm" onclick="gestionarVideo(<?php echo htmlspecialchars(json_encode($v)); ?>)" title="Editar">
|
||||
<button class="btn btn-sm btn-light border shadow-sm" data-video-id="<?php echo $v['id']; ?>" data-video-payload="<?php echo htmlspecialchars(json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), ENT_QUOTES, 'UTF-8'); ?>" onclick="gestionarVideoFromButton(this)" title="Editar">
|
||||
<i class="fas fa-edit text-primary"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light border shadow-sm" onclick="eliminarVideo(<?php echo $v['id']; ?>)" title="Eliminar">
|
||||
@ -508,15 +601,8 @@ foreach ($videos as $v) {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="video-card-status">
|
||||
<span class="badge status-badge <?php
|
||||
echo $v['estado'] == 'PENDIENTE' ? 'bg-warning text-dark' :
|
||||
($v['estado'] == 'EN PROCESO' ? 'bg-info text-white' :
|
||||
($v['estado'] == 'TERMINADO' ? 'bg-primary text-white' :
|
||||
($v['estado'] == 'OBSERVADO' ? 'bg-danger text-white' :
|
||||
($v['estado'] == 'APROBADO' ? 'bg-success text-white' :
|
||||
($v['estado'] == 'PUBLICADO' ? 'bg-dark text-white' : 'bg-secondary text-white')))));
|
||||
?> shadow-sm">
|
||||
<?php echo $v['estado']; ?>
|
||||
<span class="badge status-badge video-card-status-badge <?php echo marketing_status_badge_class($v['estado']); ?> shadow-sm" data-video-id="<?php echo $v['id']; ?>">
|
||||
<?php echo htmlspecialchars($v['estado']); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -555,7 +641,7 @@ foreach ($videos as $v) {
|
||||
<div class="card-link-badge text-success" style="background: #dcfce7;" title="Video Final"><i class="fas fa-play"></i></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary rounded-pill px-3" onclick="gestionarVideo(<?php echo htmlspecialchars(json_encode($v)); ?>)">
|
||||
<button class="btn btn-sm btn-primary rounded-pill px-3" data-video-id="<?php echo $v['id']; ?>" data-video-payload="<?php echo htmlspecialchars(json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), ENT_QUOTES, 'UTF-8'); ?>" onclick="gestionarVideoFromButton(this)">
|
||||
Gestionar
|
||||
</button>
|
||||
</div>
|
||||
@ -838,9 +924,210 @@ function switchView(view) {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
switch (status) {
|
||||
case 'PENDIENTE':
|
||||
return 'bg-warning text-dark';
|
||||
case 'EN PROCESO':
|
||||
return 'bg-info text-white';
|
||||
case 'TERMINADO':
|
||||
return 'bg-primary text-white';
|
||||
case 'OBSERVADO':
|
||||
return 'bg-danger text-white';
|
||||
case 'APROBADO':
|
||||
return 'bg-success text-white';
|
||||
case 'PUBLICADO':
|
||||
return 'bg-dark text-white';
|
||||
default:
|
||||
return 'bg-secondary text-white';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCardStatusBadge(videoId, status) {
|
||||
document.querySelectorAll(`.video-card-status-badge[data-video-id="${videoId}"]`).forEach(badge => {
|
||||
badge.className = `badge status-badge video-card-status-badge ${getStatusBadgeClass(status)} shadow-sm`;
|
||||
badge.textContent = status || '-';
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatusCounter(status, delta) {
|
||||
const counters = {
|
||||
'PENDIENTE': 'status-count-pendiente',
|
||||
'EN PROCESO': 'status-count-en-proceso',
|
||||
'TERMINADO': 'status-count-terminado',
|
||||
'APROBADO': 'status-count-aprobado'
|
||||
};
|
||||
|
||||
const counterId = counters[status];
|
||||
if (!counterId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const counter = document.getElementById(counterId);
|
||||
if (!counter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = parseInt(counter.textContent, 10);
|
||||
if (Number.isNaN(currentValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
counter.textContent = Math.max(0, currentValue + delta);
|
||||
}
|
||||
|
||||
function syncVideoPayload(videoId, field, value) {
|
||||
document.querySelectorAll(`[data-video-id="${videoId}"][data-video-payload]`).forEach(button => {
|
||||
try {
|
||||
const payload = JSON.parse(button.dataset.videoPayload);
|
||||
payload[field] = value;
|
||||
button.dataset.videoPayload = JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
console.error('No se pudo sincronizar el payload del video.', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function gestionarVideoFromButton(button) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPayload = button.getAttribute('data-video-payload');
|
||||
if (!rawPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
gestionarVideo(JSON.parse(rawPayload));
|
||||
} catch (error) {
|
||||
console.error('No se pudo abrir el modal del video.', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedView = localStorage.getItem('video_production_view') || 'table';
|
||||
switchView(savedView);
|
||||
|
||||
const observationInputs = document.querySelectorAll('.observation-input');
|
||||
observationInputs.forEach(input => {
|
||||
let lastSavedValue = input.value;
|
||||
const status = document.getElementById(`observation-status-${input.dataset.id}`);
|
||||
|
||||
const setStatus = (message, className = 'text-muted') => {
|
||||
if (!status) return;
|
||||
status.className = `small mt-1 observation-status ${className}`;
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const saveObservation = () => {
|
||||
const newValue = input.value;
|
||||
if (newValue === lastSavedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Guardando...', 'text-primary');
|
||||
|
||||
fetch('update_marketing_video_field.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: input.dataset.id,
|
||||
field: 'observacion',
|
||||
value: newValue
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lastSavedValue = newValue;
|
||||
setStatus('Guardado', 'text-success');
|
||||
setTimeout(() => {
|
||||
if (status && status.textContent === 'Guardado') {
|
||||
setStatus('', 'text-muted');
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
setStatus('Error al guardar', 'text-danger');
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('Error al guardar', 'text-danger');
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('blur', saveObservation);
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const statusSelects = document.querySelectorAll('.status-select');
|
||||
statusSelects.forEach(select => {
|
||||
let lastSavedValue = select.value;
|
||||
const feedback = document.getElementById(`status-feedback-${select.dataset.id}`);
|
||||
|
||||
const setFeedback = (message, className = 'text-muted') => {
|
||||
if (!feedback) return;
|
||||
feedback.className = `small mt-1 status-feedback ${className}`;
|
||||
feedback.textContent = message;
|
||||
};
|
||||
|
||||
select.addEventListener('change', function() {
|
||||
const newValue = select.value;
|
||||
const previousValue = lastSavedValue;
|
||||
|
||||
if (newValue === previousValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
select.disabled = true;
|
||||
select.dataset.status = newValue;
|
||||
setFeedback('Guardando...', 'text-primary');
|
||||
|
||||
fetch('update_marketing_video_field.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: select.dataset.id,
|
||||
field: 'estado',
|
||||
value: newValue
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lastSavedValue = newValue;
|
||||
syncVideoPayload(select.dataset.id, 'estado', newValue);
|
||||
updateCardStatusBadge(select.dataset.id, newValue);
|
||||
updateStatusCounter(previousValue, -1);
|
||||
updateStatusCounter(newValue, 1);
|
||||
setFeedback('Guardado', 'text-success');
|
||||
setTimeout(() => {
|
||||
if (feedback && feedback.textContent === 'Guardado') {
|
||||
setFeedback('', 'text-muted');
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
select.value = previousValue;
|
||||
select.dataset.status = previousValue;
|
||||
setFeedback('Error al guardar', 'text-danger');
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
select.value = previousValue;
|
||||
select.dataset.status = previousValue;
|
||||
setFeedback('Error al guardar', 'text-danger');
|
||||
})
|
||||
.finally(() => {
|
||||
select.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function addLinkInput(containerId, inputName, value = '') {
|
||||
@ -978,6 +1265,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (data.success) {
|
||||
// Actualizar data-value para la próxima edición
|
||||
this.setAttribute('data-value', newValue);
|
||||
syncVideoPayload(id, field, newValue);
|
||||
|
||||
if (field === 'estado') {
|
||||
let badgeClass = 'bg-secondary';
|
||||
|
||||
@ -250,6 +250,7 @@ include 'layout_header.php';
|
||||
<div class="mt-1">
|
||||
<button type="button" class="btn btn-sm btn-info" title="Consultar Estado" data-bs-toggle="modal" data-bs-target="#trackingModal" data-order-number="<?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?>" data-order-code="<?php echo htmlspecialchars($pedido['codigo_tracking'] ?? 'N/A'); ?>" data-whatsapp-message="<?php echo htmlspecialchars($whatsappMessageRaw); ?>">🔍</button>
|
||||
<a href="https://rastrea.shalom.pe/rastrea" target="_blank" class="btn btn-sm btn-primary" title="Rastreo Shalom">🚚</a>
|
||||
<button type='button' class='btn btn-sm btn-success copy-summary-icon' title='Copiar Resumen para Cliente' data-whatsapp-message="<?php echo htmlspecialchars($whatsappMessageRaw); ?>">📋</button>
|
||||
<a href="<?php echo $whatsappUrl; ?>" target="_blank" class="btn btn-sm btn-secondary whatsapp-icon" id="whatsapp-icon-<?php echo $pedido['id']; ?>" title="Enviar WhatsApp">💬</a>
|
||||
</div>
|
||||
</td>
|
||||
@ -522,6 +523,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
verifyButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Verificando...';
|
||||
}
|
||||
|
||||
const setStatusCell = (cell, badgeClass, text, title = '') => {
|
||||
cell.innerHTML = '';
|
||||
const span = document.createElement('span');
|
||||
span.className = `badge ${badgeClass}`;
|
||||
span.textContent = text;
|
||||
if (title) span.title = title;
|
||||
cell.appendChild(span);
|
||||
};
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
setTimeout(() => {
|
||||
@ -533,65 +542,121 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
if (!statusCell) return;
|
||||
|
||||
const reenableIfLast = () => {
|
||||
if (index === rows.length - 1 && verifyButton) {
|
||||
verifyButton.disabled = false;
|
||||
verifyButton.innerHTML = 'Verificar Estados Ahora';
|
||||
}
|
||||
};
|
||||
|
||||
if (orderCode === '26') {
|
||||
statusCell.innerHTML = '<span class="badge bg-info">OLVA COURIER</span>';
|
||||
setStatusCell(statusCell, 'bg-info', 'OLVA COURIER');
|
||||
reenableIfLast();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orderNumber || !orderCode || orderNumber === 'N/A' || orderCode === 'N/A') {
|
||||
statusCell.innerHTML = '<span class="badge bg-light text-dark">Sin datos</span>';
|
||||
setStatusCell(statusCell, 'bg-light text-dark', 'Sin datos');
|
||||
reenableIfLast();
|
||||
return;
|
||||
}
|
||||
|
||||
statusCell.innerHTML = '<span class="badge bg-info text-dark">Verificando...</span>';
|
||||
setStatusCell(statusCell, 'bg-info text-dark', 'Verificando...');
|
||||
|
||||
fetch(`shalom_api.php?orderNumber=${encodeURIComponent(orderNumber)}&orderCode=${encodeURIComponent(orderCode)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) { throw new Error('Network response was not ok.'); }
|
||||
return response.json();
|
||||
.then(async response => {
|
||||
let data = null;
|
||||
try { data = await response.json(); } catch (e) { /* ignore */ }
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = (data && data.error) ? data.error : `HTTP ${response.status}`;
|
||||
const details = (data && data.details) ? data.details : '';
|
||||
let detailsText = '';
|
||||
|
||||
if (details) {
|
||||
detailsText = typeof details === 'string' ? details : JSON.stringify(details);
|
||||
}
|
||||
|
||||
// Devolvemos el error dentro de JSON para que el siguiente then lo pinte como Error
|
||||
return {
|
||||
error: msg,
|
||||
details: detailsText,
|
||||
http_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
statusCell.innerHTML = `<span class="badge bg-danger" title="${data.error}">Error</span>`;
|
||||
} else if (data.statuses && data.statuses.message) {
|
||||
if (data && data.error) {
|
||||
const details = data.details
|
||||
? (typeof data.details === 'string' ? data.details : JSON.stringify(data.details))
|
||||
: '';
|
||||
|
||||
const title = details ? `${data.error} - ${details}` : data.error;
|
||||
|
||||
const errText = (data.error || '').toString();
|
||||
|
||||
const codeMatch = errText.match(/c[oó]digo\s*(\d{3,5})/i);
|
||||
const codeMatch2 = !codeMatch ? errText.match(/Shalom\s*(\d{3,5})/i) : null;
|
||||
|
||||
let badgeText = 'Error Shalom';
|
||||
if (codeMatch) {
|
||||
badgeText = `Shalom ${codeMatch[1]}`;
|
||||
} else if (codeMatch2) {
|
||||
badgeText = `Shalom ${codeMatch2[1]}`;
|
||||
} else {
|
||||
const isTimeout = /timed out|timeout/i.test(errText) || /timed out|timeout/i.test(details);
|
||||
if (isTimeout) {
|
||||
badgeText = 'Shalom timeout';
|
||||
} else if (/comunicaci[oó]n/i.test(errText)) {
|
||||
badgeText = 'Error API Shalom';
|
||||
} else {
|
||||
const shortErr = errText.length > 22 ? errText.slice(0, 22) + '…' : errText;
|
||||
badgeText = shortErr || 'Error Shalom';
|
||||
}
|
||||
}
|
||||
|
||||
setStatusCell(statusCell, 'bg-danger', badgeText, title);
|
||||
} else if (data && data.statuses && data.statuses.message) {
|
||||
const statusMessage = data.statuses.message;
|
||||
let badgeClass = 'bg-secondary';
|
||||
let whatsappClass = 'btn-secondary';
|
||||
|
||||
if (statusMessage.toUpperCase().includes('EN DESTINO')) {
|
||||
const upper = (statusMessage || '').toUpperCase();
|
||||
|
||||
if (upper.includes('EN DESTINO')) {
|
||||
badgeClass = 'bg-success';
|
||||
whatsappClass = 'btn-success'; // Green
|
||||
} else if (statusMessage.toUpperCase().includes('EN TRANSITO')) {
|
||||
whatsappClass = 'btn-success';
|
||||
} else if (upper.includes('EN TRANSITO')) {
|
||||
badgeClass = 'bg-primary';
|
||||
} else if (statusMessage.toUpperCase().includes('REGISTRADO')) {
|
||||
} else if (upper.includes('REGISTRADO')) {
|
||||
badgeClass = 'bg-warning text-dark';
|
||||
}
|
||||
|
||||
statusCell.innerHTML = `<span class="badge ${badgeClass}">${statusMessage}</span>`;
|
||||
|
||||
|
||||
setStatusCell(statusCell, badgeClass, statusMessage);
|
||||
|
||||
if (whatsappIcon) {
|
||||
whatsappIcon.classList.remove('btn-secondary', 'btn-success');
|
||||
whatsappIcon.classList.add(whatsappClass);
|
||||
}
|
||||
} else {
|
||||
statusCell.innerHTML = '<span class="badge bg-warning text-dark">Inválido</span>';
|
||||
setStatusCell(statusCell, 'bg-warning text-dark', 'Inválido');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching status:', error);
|
||||
statusCell.innerHTML = '<span class="badge bg-danger">Fallo</span>';
|
||||
|
||||
const fullMessage = error && error.message ? error.message : 'Error al consultar';
|
||||
const shortErr = fullMessage.length > 22 ? fullMessage.slice(0, 22) + '…' : fullMessage;
|
||||
setStatusCell(statusCell, 'bg-danger', shortErr, fullMessage);
|
||||
})
|
||||
.finally(() => {
|
||||
// Re-enable button after the last request is done
|
||||
if (index === rows.length - 1 && verifyButton) {
|
||||
verifyButton.disabled = false;
|
||||
verifyButton.innerHTML = 'Verificar Estados Ahora';
|
||||
}
|
||||
reenableIfLast();
|
||||
});
|
||||
}, index * 500); // Stagger requests to avoid overwhelming the server/API
|
||||
});
|
||||
}
|
||||
|
||||
// Initial check on page load
|
||||
verificarEstados();
|
||||
|
||||
@ -600,6 +665,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (verifyButton) {
|
||||
verifyButton.addEventListener('click', verificarEstados);
|
||||
}
|
||||
|
||||
// Copiar resumen desde el icono de la columna Celular
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target && e.target.closest ? e.target.closest('.copy-summary-icon') : null;
|
||||
if (!btn) return;
|
||||
|
||||
const message = btn.getAttribute('data-whatsapp-message') || '';
|
||||
if (!message) {
|
||||
alert('No hay resumen disponible para copiar.');
|
||||
return;
|
||||
}
|
||||
|
||||
copyStatusToClipboard(message);
|
||||
});
|
||||
});
|
||||
|
||||
function copyStatusToClipboard(message) {
|
||||
|
||||
173
shalom_api.php
173
shalom_api.php
@ -1,63 +1,164 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// 1. Validar parámetros de entrada
|
||||
$orderNumber = $_GET['orderNumber'] ?? null;
|
||||
$orderCode = $_GET['orderCode'] ?? null;
|
||||
// Inputs
|
||||
$orderNumber = trim($_GET['orderNumber'] ?? '');
|
||||
$orderCode = trim($_GET['orderCode'] ?? '');
|
||||
|
||||
if (!$orderNumber || !$orderCode) {
|
||||
if ($orderNumber === '' || $orderCode === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Número de orden y código de orden son requeridos.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Configurar la llamada a la API de Shalom
|
||||
$apiKey = 'sk_mlq1j3na_4a676ewvaop';
|
||||
$url = "https://shalom-api.lat/api/track"; // Endpoint correcto para POST
|
||||
// Load env from executor/.env (helps when Apache/PHP doesn't export .env vars)
|
||||
function load_dotenv_if_needed(array $keys): void {
|
||||
$missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === '');
|
||||
if (empty($missing)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Datos para el cuerpo de la solicitud POST
|
||||
$postData = [
|
||||
'orderNumber' => $orderNumber,
|
||||
'orderCode' => $orderCode
|
||||
];
|
||||
$jsonData = json_encode($postData);
|
||||
static $loaded = false;
|
||||
if ($loaded) return;
|
||||
|
||||
$ch = curl_init();
|
||||
$envPath = realpath(__DIR__ . '/../.env'); // executor/.env
|
||||
if ($envPath && is_readable($envPath)) {
|
||||
$lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '' || $trimmed[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($trimmed, '=')) continue;
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true); // Especificar que es una solicitud POST
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData); // Enviar los datos en formato JSON
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: Bearer {$apiKey}"
|
||||
]);
|
||||
[$k, $v] = array_map('trim', explode('=', $trimmed, 2));
|
||||
if ($k === '') continue;
|
||||
|
||||
// 3. Ejecutar la llamada y obtener la respuesta
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
// Strip potential surrounding quotes
|
||||
$v = trim($v, "\"' ");
|
||||
|
||||
// 4. Manejar la respuesta
|
||||
if ($error) {
|
||||
// Do not override existing process env
|
||||
if (getenv($k) === false || getenv($k) === '') {
|
||||
putenv("{$k}={$v}");
|
||||
}
|
||||
}
|
||||
$loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
load_dotenv_if_needed(['SHALOM_API_KEY']);
|
||||
|
||||
// API
|
||||
$apiKey = getenv('SHALOM_API_KEY');
|
||||
|
||||
// Fallback: allow setting the key from the DB (so the app works even if you can't edit .env)
|
||||
if (!$apiKey) {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('SELECT valor FROM configuracion WHERE clave = ? LIMIT 1');
|
||||
$stmt->execute(['SHALOM_API_KEY']);
|
||||
$apiKey = $stmt->fetchColumn();
|
||||
if (is_string($apiKey)) {
|
||||
$apiKey = trim($apiKey);
|
||||
} else {
|
||||
$apiKey = null;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$apiKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$apiKey) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Error en la comunicación con la API de Shalom: ' . $error]);
|
||||
echo json_encode([
|
||||
'error' => 'Falta SHALOM_API_KEY. Configura la clave en Configuración General (Administrador) o en tu .env para poder consultar el tracking.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$url = 'https://shalom-api.lat/api/track';
|
||||
|
||||
$postData = [
|
||||
'orderNumber' => $orderNumber,
|
||||
'orderCode' => $orderCode,
|
||||
];
|
||||
|
||||
$jsonData = json_encode($postData, JSON_UNESCAPED_UNICODE);
|
||||
if ($jsonData === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Error serializando la solicitud a Shalom.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $jsonData,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: Bearer {$apiKey}",
|
||||
],
|
||||
// Timeouts: evitamos que la UI quede esperando indefinidamente
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
http_response_code(502);
|
||||
echo json_encode([
|
||||
'error' => 'Error en la comunicación con la API de Shalom',
|
||||
'details' => $curlError,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($response === false || $response === null || $response === '') {
|
||||
http_response_code(502);
|
||||
echo json_encode([
|
||||
'error' => 'Shalom no devolvió respuesta',
|
||||
'http_code' => $httpCode ?: null,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Error HTTP del proveedor
|
||||
if ($httpCode >= 400) {
|
||||
http_response_code($httpCode);
|
||||
// Intentar decodificar el cuerpo del error si Shalom lo envía en JSON
|
||||
|
||||
$errorBody = json_decode($response, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && isset($errorBody['message'])) {
|
||||
echo json_encode(['error' => "Error de la API de Shalom: " . $errorBody['message']]);
|
||||
$shMessage = null;
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($errorBody)) {
|
||||
$shMessage = $errorBody['message'] ?? $errorBody['error'] ?? null;
|
||||
}
|
||||
|
||||
if ($shMessage) {
|
||||
echo json_encode([
|
||||
'error' => "Error de la API de Shalom: {$shMessage}",
|
||||
'http_code' => $httpCode,
|
||||
'details' => $errorBody ?: $response,
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['error' => "Error de la API de Shalom (código {$httpCode}).", 'details' => $response]);
|
||||
echo json_encode([
|
||||
'error' => "Error de la API de Shalom (código {$httpCode}).",
|
||||
'http_code' => $httpCode,
|
||||
'details' => $response,
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Devolver la respuesta exitosa al cliente
|
||||
// OK: devolvemos tal cual lo que responde Shalom (la UI espera JSON con search/statuses)
|
||||
echo $response;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once 'db/config.php';
|
||||
require_once 'includes/marketing_bootstrap.php';
|
||||
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
http_response_code(403);
|
||||
@ -16,21 +18,45 @@ if (!$data || !isset($data['id']) || !isset($data['field']) || !isset($data['val
|
||||
exit;
|
||||
}
|
||||
|
||||
$video_id = $data['id'];
|
||||
$video_id = (int)($data['id'] ?? 0);
|
||||
$field = $data['field'];
|
||||
$value = $data['value'];
|
||||
|
||||
if ($video_id <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'ID de video no válido.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Valor no válido.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$value = is_string($value) ? trim($value) : (string)$value;
|
||||
|
||||
// Whitelist allowed fields for security
|
||||
$allowed_fields = ['material', 'estado', 'angulo_video'];
|
||||
if (!in_array($field, $allowed_fields)) {
|
||||
$allowed_fields = ['material', 'estado', 'angulo_video', 'observacion'];
|
||||
if (!in_array($field, $allowed_fields, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Campo no permitido.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($field === 'estado') {
|
||||
$allowedStatuses = ['PENDIENTE', 'EN PROCESO', 'TERMINADO', 'OBSERVADO', 'APROBADO', 'PUBLICADO'];
|
||||
if (!in_array($value, $allowedStatuses, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Estado no permitido.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
marketing_ensure_schema($pdo);
|
||||
|
||||
$sql = "UPDATE marketing_videos SET $field = ? WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$value, $video_id]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user