Autosave: 20260707-015607

This commit is contained in:
Flatlogic Bot 2026-07-07 01:55:09 +00:00
parent e282778646
commit 2cde551bb9
7 changed files with 669 additions and 93 deletions

View File

@ -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">

View File

@ -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;

View 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);
}
}

View File

@ -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';

View File

@ -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[]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[]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) {

View File

@ -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;

View File

@ -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]);