Autosave: 20260708-065342

This commit is contained in:
Flatlogic Bot 2026-07-08 06:52:42 +00:00
parent c4432f5693
commit 6c3b83d672
9 changed files with 815 additions and 54 deletions

View File

@ -411,4 +411,20 @@ h1, .h1 {
.table td, .table th {
padding: 0.5rem;
}
}
}
/* Call center: Estado badge colors */
.cc-badge-orange {
background-color: #ffb74d !important;
color: #9a5a00 !important;
border: 1px solid #ff9800 !important;
}
/* Call center: Estado de fila (sombreado completo) */
.cc-callcenter-row td {
background-color: var(--cc-row-bg, #ffffff) !important;
}
.cc-callcenter-row:hover td {
background-color: var(--cc-row-bg, #ffffff) !important;
}

View File

@ -84,16 +84,18 @@ function cc_test_order_label(array $order): string
function cc_test_badge_class(string $estado): string
{
return match (cc_test_normalize_state($estado)) {
'CONFIRMADO CONTRAENTREGA', 'CONFIRMADO ENVIO' => 'bg-success-subtle text-success-emphasis',
'DEVOLVER LLAMADA' => 'bg-info-subtle text-info-emphasis',
'POR LLAMAR', 'DEVOLVER LLAMADA' => 'bg-white text-dark border',
'OBSERVADO' => 'bg-warning-subtle text-warning-emphasis',
'SE ENVIO NUMERO DE CUENTA' => 'bg-success-subtle text-success-emphasis',
'CONFIRMADO CONTRAENTREGA' => 'cc-badge-orange',
'CONFIRMADO ENVIO' => 'bg-success text-white',
'CANCELADO' => 'bg-danger-subtle text-danger-emphasis',
'REPETIDO' => 'bg-secondary-subtle text-secondary-emphasis',
'SE ENVIO NUMERO DE CUENTA' => 'bg-primary-subtle text-primary-emphasis',
default => 'bg-dark-subtle text-dark-emphasis',
default => 'bg-white text-dark border',
};
}
function cc_test_order_time(array $order): int
{
foreach (['proxima_llamada_at', 'numero_cuenta_enviado_at', 'ultima_gestion_at', 'seguimiento_actualizado', 'import_id'] as $field) {
@ -117,7 +119,7 @@ function cc_test_followup_semaforo(array $order): ?array
$view = $_GET['view'] ?? 'pendientes_hoy';
$allowedViews = [
'pendientes_hoy' => 'Pendientes de hoy',
'pendientes_hoy' => 'Pendientes por llamar',
'nuevos_hoy' => 'Nuevos de hoy',
'confirmados' => 'Confirmados',
'seguimiento' => 'Seguimiento',
@ -191,7 +193,27 @@ try {
throw new RuntimeException('No se encontró la asesora seleccionada.');
}
cc_test_upsert_assignee($pdo, $sourceKey, $selectedAssessor ? (int) $selectedAssessor['id'] : null);
$targetUserId = $selectedAssessor ? (int) $selectedAssessor['id'] : null;
$stmtCurrent = $pdo->prepare('SELECT user_id FROM callcenter_test_tracking WHERE source_key = ? LIMIT 1');
$stmtCurrent->execute([$sourceKey]);
$currentUserIdRaw = $stmtCurrent->fetchColumn();
$currentUserId = $currentUserIdRaw !== null ? ((int) $currentUserIdRaw > 0 ? (int) $currentUserIdRaw : null) : null;
// Si el pedido ya está asignado, evitamos que se reasigne a otra asesora (solo permitimos dejarlo sin asignar).
if ($currentUserId !== null && $targetUserId !== null && (int) $currentUserId !== (int) $targetUserId) {
$currentLabel = 'otra asesora';
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $currentUserId) {
$currentLabel = (string) ($assessor['label'] ?? $assessorKey);
break;
}
}
throw new RuntimeException('Este pedido ya está asignado a ' . $currentLabel . '. Para cambiarlo primero déjalo en "Sin asignar".');
}
cc_test_upsert_assignee($pdo, $sourceKey, $targetUserId);
$noticeMessage = $selectedAssessor
? 'Pedido asignado a ' . $selectedAssessor['label'] . '.'
: 'Pedido dejado sin asignar.';
@ -229,8 +251,7 @@ try {
$proximaDate = cc_test_parse_datetime($order['proxima_llamada_at'] ?? null);
$order['es_nuevo_hoy'] = $importDate ? $importDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_pendiente_hoy'] = in_array($order['estado'], $openStates, true)
&& ($proximaDate === null || $proximaDate <= $todayEnd);
$order['es_pendiente_hoy'] = in_array($order['estado'], $openStates, true);
$order['es_cerrado'] = in_array($order['estado'], $closedStates, true);
$stats['total']++;
@ -336,7 +357,7 @@ require_once 'layout_header.php';
<a href="?view=pendientes_hoy&store=<?php echo htmlspecialchars($storeKey); ?>" class="text-decoration-none">
<article class="card border-0 shadow-sm h-100 <?php echo $view === 'pendientes_hoy' ? 'bg-dark text-white' : 'bg-white'; ?>">
<div class="card-body">
<div class="small text-uppercase opacity-75 mb-2">Pendientes de hoy</div>
<div class="small text-uppercase opacity-75 mb-2">Pendientes por llamar</div>
<div class="display-6 fw-bold mb-0"><?php echo (int) $stats['pendientes_hoy']; ?></div>
</div>
</article>
@ -447,7 +468,7 @@ require_once 'layout_header.php';
$historial = cc_test_fetch_historial(db(), $order['source_key']);
ob_start();
?>
<tr data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>">
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars(cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR'))); ?>">
<td>
<div class="fw-bold fs-6"><?php echo htmlspecialchars(cc_test_order_label($order)); ?></div>
<div class="small text-muted">ID Drive: <?php echo htmlspecialchars(cc_test_display_value($order['import_id'] ?? null, 'Sin ID')); ?></div>
@ -494,18 +515,69 @@ require_once 'layout_header.php';
<td class="text-center">
<div class="d-flex flex-column gap-2 align-items-stretch">
<?php if (in_array($_SESSION['user_role'] ?? '', ['Administrador', 'admin'], true)): ?>
<form method="post" class="border rounded-3 bg-light p-2 text-start shadow-sm">
<input type="hidden" name="action" value="assign_assessor">
<input type="hidden" name="source_key" value="<?php echo htmlspecialchars($order['source_key']); ?>">
<div class="small text-uppercase text-muted fw-semibold mb-1">Asignar a asesora</div>
<select name="target_assessor" class="form-select form-select-sm mb-2">
<option value=""<?php echo empty($order['user_id']) ? ' selected' : ''; ?>>Sin asignar</option>
<?php foreach ($assessors as $assessorKey => $assessor): ?>
<option value="<?php echo htmlspecialchars($assessorKey); ?>"<?php echo ((int) ($order['user_id'] ?? 0) === (int) ($assessor['id'] ?? 0)) ? ' selected' : ''; ?>><?php echo htmlspecialchars($assessor['label']); ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-sm btn-primary w-100">Asignar pedido</button>
</form>
<?php
$orderUserId = $order['user_id'] ?? null;
$orderUserIdNorm = $orderUserId !== null ? ((int) $orderUserId > 0 ? (int) $orderUserId : null) : null;
$unassigned = $orderUserIdNorm === null;
$assignedAssessorKey = null;
if (!$unassigned) {
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $orderUserIdNorm) {
$assignedAssessorKey = (string) $assessorKey;
break;
}
}
}
$assignFormClass = 'border rounded-3 p-2 text-start shadow-sm';
if ($unassigned) {
$assignFormClass .= ' bg-light';
} elseif ($assignedAssessorKey === 'KARINA') {
$assignFormClass .= ' bg-primary-subtle border-primary';
} elseif ($assignedAssessorKey === 'ESTEFANYA') {
$assignFormClass .= ' bg-success-subtle border-success';
} else {
$assignFormClass .= ' bg-secondary-subtle border-secondary';
}
$selectedAssessorKeyForSelect = $unassigned ? '' : ($assignedAssessorKey !== null ? (string) $assignedAssessorKey : '');
$buttonLabel = $unassigned
? 'Asignar pedido'
: ($assignedAssessorKey !== null ? 'Guardar asignación' : 'Desasignar');
?>
<form method="post" class="<?php echo $assignFormClass; ?>">
<input type="hidden" name="action" value="assign_assessor">
<input type="hidden" name="source_key" value="<?php echo htmlspecialchars($order['source_key']); ?>">
<div class="d-flex justify-content-between align-items-center gap-2 mb-1">
<div class="small text-uppercase text-muted fw-semibold">Asignar a asesora</div>
<?php if ($unassigned): ?>
<span class="badge bg-light text-dark border">Sin asignar</span>
<?php elseif ($assignedAssessorKey === 'KARINA'): ?>
<span class="badge bg-primary-subtle text-primary-emphasis border">KARINA</span>
<?php elseif ($assignedAssessorKey === 'ESTEFANYA'): ?>
<span class="badge bg-success-subtle text-success-emphasis border">ESTEFANYA</span>
<?php else: ?>
<span class="badge bg-secondary-subtle text-secondary-emphasis border">Asignado</span>
<?php endif; ?>
</div>
<select name="target_assessor" class="form-select form-select-sm mb-2">
<option value=""<?php echo ($selectedAssessorKeyForSelect === '') ? ' selected' : ''; ?>>Sin asignar</option>
<?php if ($unassigned): ?>
<?php foreach ($assessors as $assessorKey => $assessor): ?>
<option value="<?php echo htmlspecialchars($assessorKey); ?>"<?php echo ($selectedAssessorKeyForSelect === $assessorKey) ? ' selected' : ''; ?>><?php echo htmlspecialchars($assessor['label']); ?></option>
<?php endforeach; ?>
<?php elseif ($assignedAssessorKey !== null): ?>
<option value="<?php echo htmlspecialchars((string) $assignedAssessorKey); ?>" selected><?php echo htmlspecialchars((string) ($assessors[$assignedAssessorKey]['label'] ?? $assignedAssessorKey)); ?></option>
<?php endif; ?>
</select>
<button type="submit" class="btn btn-sm btn-primary w-100"><?php echo htmlspecialchars((string) $buttonLabel); ?></button>
</form>
<?php endif; ?>
<?php if (!empty($order['telefono_url'])): ?>
<button

View File

@ -1,3 +1,3 @@
-- Agrega la columna de observación para Producción de Videos
ALTER TABLE marketing_videos
ADD COLUMN observacion TEXT NULL AFTER estado;
ADD COLUMN IF NOT EXISTS observacion TEXT NULL AFTER estado;

View File

@ -0,0 +1,13 @@
-- Contraentrega: verificación con imagen + tracking para que otra persona lo copie a 'seguimiento'
ALTER TABLE pedidos
ADD COLUMN IF NOT EXISTS contraentrega_verificacion_imagen_path VARCHAR(500) NULL;
ALTER TABLE pedidos
ADD COLUMN IF NOT EXISTS contraentrega_verificacion_tracking VARCHAR(255) NULL;
ALTER TABLE pedidos
ADD COLUMN IF NOT EXISTS contraentrega_verificacion_user_id INT NULL;
ALTER TABLE pedidos
ADD COLUMN IF NOT EXISTS contraentrega_verificacion_created_at TIMESTAMP NULL DEFAULT NULL;

View File

@ -84,16 +84,18 @@ function cc_test_order_label(array $order): string
function cc_test_badge_class(string $estado): string
{
return match (cc_test_normalize_state($estado)) {
'CONFIRMADO CONTRAENTREGA', 'CONFIRMADO ENVIO' => 'bg-success-subtle text-success-emphasis',
'DEVOLVER LLAMADA' => 'bg-info-subtle text-info-emphasis',
'POR LLAMAR', 'DEVOLVER LLAMADA' => 'bg-white text-dark border',
'OBSERVADO' => 'bg-warning-subtle text-warning-emphasis',
'SE ENVIO NUMERO DE CUENTA' => 'bg-success-subtle text-success-emphasis',
'CONFIRMADO CONTRAENTREGA' => 'cc-badge-orange',
'CONFIRMADO ENVIO' => 'bg-success text-white',
'CANCELADO' => 'bg-danger-subtle text-danger-emphasis',
'REPETIDO' => 'bg-secondary-subtle text-secondary-emphasis',
'SE ENVIO NUMERO DE CUENTA' => 'bg-primary-subtle text-primary-emphasis',
default => 'bg-dark-subtle text-dark-emphasis',
default => 'bg-white text-dark border',
};
}
function cc_test_order_time(array $order): int
{
foreach (['proxima_llamada_at', 'numero_cuenta_enviado_at', 'ultima_gestion_at', 'seguimiento_actualizado', 'import_id'] as $field) {
@ -117,7 +119,7 @@ function cc_test_followup_semaforo(array $order): ?array
$view = $_GET['view'] ?? 'pendientes_hoy';
$allowedViews = [
'pendientes_hoy' => 'Pendientes de hoy',
'pendientes_hoy' => 'Pendientes por llamar',
'nuevos_hoy' => 'Nuevos de hoy',
'confirmados' => 'Confirmados',
'seguimiento' => 'Seguimiento',
@ -196,7 +198,27 @@ try {
throw new RuntimeException('No se encontró la asesora seleccionada.');
}
cc_test_upsert_assignee($pdo, $sourceKey, $selectedAssessor ? (int) $selectedAssessor['id'] : null);
$targetUserId = $selectedAssessor ? (int) $selectedAssessor['id'] : null;
$stmtCurrent = $pdo->prepare('SELECT user_id FROM callcenter_test_tracking WHERE source_key = ? LIMIT 1');
$stmtCurrent->execute([$sourceKey]);
$currentUserIdRaw = $stmtCurrent->fetchColumn();
$currentUserId = $currentUserIdRaw !== null ? ((int) $currentUserIdRaw > 0 ? (int) $currentUserIdRaw : null) : null;
// Si el pedido ya está asignado, evitamos que se reasigne a otra asesora (solo permitimos dejarlo sin asignar).
if ($currentUserId !== null && $targetUserId !== null && (int) $currentUserId !== (int) $targetUserId) {
$currentLabel = 'otra asesora';
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $currentUserId) {
$currentLabel = (string) ($assessor['label'] ?? $assessorKey);
break;
}
}
throw new RuntimeException('Este pedido ya está asignado a ' . $currentLabel . '. Para cambiarlo primero déjalo en "Sin asignar".');
}
cc_test_upsert_assignee($pdo, $sourceKey, $targetUserId);
$noticeMessage = $selectedAssessor
? 'Pedido asignado a ' . $selectedAssessor['label'] . '.'
: 'Pedido dejado sin asignar.';
@ -375,8 +397,7 @@ try {
$proximaDate = cc_test_parse_datetime($order['proxima_llamada_at'] ?? null);
$order['es_nuevo_hoy'] = $importDate ? $importDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_pendiente_hoy'] = in_array($order['estado'], $openStates, true)
&& ($proximaDate === null || $proximaDate <= $todayEnd);
$order['es_pendiente_hoy'] = in_array($order['estado'], $openStates, true);
$order['es_cerrado'] = in_array($order['estado'], $closedStates, true);
if ($order['user_id'] !== null) {
@ -510,7 +531,7 @@ require_once 'layout_header.php';
<a href="?view=pendientes_hoy&store=<?php echo htmlspecialchars($storeKey); ?>" class="text-decoration-none">
<article class="card border-0 shadow-sm h-100 <?php echo $view === 'pendientes_hoy' ? 'bg-dark text-white' : 'bg-white'; ?>">
<div class="card-body">
<div class="small text-uppercase opacity-75 mb-2">Pendientes de hoy</div>
<div class="small text-uppercase opacity-75 mb-2">Pendientes por llamar</div>
<div class="display-6 fw-bold mb-0"><?php echo (int) $stats['pendientes_hoy']; ?></div>
</div>
</article>
@ -678,7 +699,7 @@ require_once 'layout_header.php';
$historial = cc_test_fetch_historial(db(), $order['source_key']);
ob_start();
?>
<tr data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>">
<tr class="cc-callcenter-row" data-source-key="<?php echo htmlspecialchars($order['source_key']); ?>" style="<?php echo htmlspecialchars(cc_test_row_background_style((string) ($order['estado'] ?? 'POR LLAMAR'))); ?>">
<td>
<div class="fw-bold fs-6"><?php echo htmlspecialchars(cc_test_order_label($order)); ?></div>
<div class="small text-muted">ID Drive: <?php echo htmlspecialchars(cc_test_display_value($order['import_id'] ?? null, 'Sin ID')); ?></div>
@ -725,23 +746,72 @@ require_once 'layout_header.php';
<td class="text-center">
<div class="d-flex flex-column gap-2 align-items-stretch">
<?php if (in_array($_SESSION['user_role'] ?? '', ['Administrador', 'admin'], true)): ?>
<form method="post" class="border rounded-3 bg-light p-2 text-start shadow-sm">
<?php
$orderUserId = $order['user_id'] ?? null;
$orderUserIdNorm = $orderUserId !== null ? ((int) $orderUserId > 0 ? (int) $orderUserId : null) : null;
$unassigned = $orderUserIdNorm === null;
$assignedAssessorKey = null;
if (!$unassigned) {
foreach ($assessors as $assessorKey => $assessor) {
if ((int) ($assessor['id'] ?? 0) === (int) $orderUserIdNorm) {
$assignedAssessorKey = (string) $assessorKey;
break;
}
}
}
$assignFormClass = 'border rounded-3 p-2 text-start shadow-sm';
if ($unassigned) {
$assignFormClass .= ' bg-light';
} elseif ($assignedAssessorKey === 'KARINA') {
$assignFormClass .= ' bg-primary-subtle border-primary';
} elseif ($assignedAssessorKey === 'ESTEFANYA') {
$assignFormClass .= ' bg-success-subtle border-success';
} else {
$assignFormClass .= ' bg-secondary-subtle border-secondary';
}
if ($unassigned) {
$selectedAssessorKeyForSelect = !empty($recommendedAssessorKey) ? (string) $recommendedAssessorKey : '';
} else {
$selectedAssessorKeyForSelect = $assignedAssessorKey !== null ? (string) $assignedAssessorKey : '';
}
$buttonLabel = $unassigned
? 'Asignar pedido'
: ($assignedAssessorKey !== null ? 'Guardar asignación' : 'Desasignar');
?>
<form method="post" class="<?php echo $assignFormClass; ?>">
<input type="hidden" name="action" value="assign_assessor">
<input type="hidden" name="source_key" value="<?php echo htmlspecialchars($order['source_key']); ?>">
<div class="small text-uppercase text-muted fw-semibold mb-1">Asignar a asesora</div>
<?php
$unassigned = ($order['user_id'] === null);
?>
<div class="d-flex justify-content-between align-items-center gap-2 mb-1">
<div class="small text-uppercase text-muted fw-semibold">Asignar a asesora</div>
<?php if ($unassigned): ?>
<span class="badge bg-light text-dark border">Sin asignar</span>
<?php elseif ($assignedAssessorKey === 'KARINA'): ?>
<span class="badge bg-primary-subtle text-primary-emphasis border">KARINA</span>
<?php elseif ($assignedAssessorKey === 'ESTEFANYA'): ?>
<span class="badge bg-success-subtle text-success-emphasis border">ESTEFANYA</span>
<?php else: ?>
<span class="badge bg-secondary-subtle text-secondary-emphasis border">Asignado</span>
<?php endif; ?>
</div>
<select name="target_assessor" class="form-select form-select-sm mb-2">
<option value=""<?php echo ($unassigned && empty($recommendedAssessorKey)) ? ' selected' : ''; ?>>Sin asignar</option>
<?php foreach ($assessors as $assessorKey => $assessor):
$isSelected = ($order['user_id'] !== null && (int) ($order['user_id'] ?? 0) === (int) ($assessor['id'] ?? 0))
|| ($unassigned && $recommendedAssessorKey === $assessorKey);
?>
<option value="<?php echo htmlspecialchars($assessorKey); ?>"<?php echo $isSelected ? ' selected' : ''; ?>><?php echo htmlspecialchars($assessor['label']); ?></option>
<?php endforeach; ?>
<option value=""<?php echo ($selectedAssessorKeyForSelect === '') ? ' selected' : ''; ?>>Sin asignar</option>
<?php if ($unassigned): ?>
<?php foreach ($assessors as $assessorKey => $assessor): ?>
<option value="<?php echo htmlspecialchars($assessorKey); ?>"<?php echo ($selectedAssessorKeyForSelect === $assessorKey) ? ' selected' : ''; ?>><?php echo htmlspecialchars($assessor['label']); ?></option>
<?php endforeach; ?>
<?php elseif ($assignedAssessorKey !== null): ?>
<option value="<?php echo htmlspecialchars((string) $assignedAssessorKey); ?>" selected><?php echo htmlspecialchars((string) ($assessors[$assignedAssessorKey]['label'] ?? $assignedAssessorKey)); ?></option>
<?php endif; ?>
</select>
<button type="submit" class="btn btn-sm btn-primary w-100">Asignar pedido</button>
<button type="submit" class="btn btn-sm btn-primary w-100"><?php echo htmlspecialchars((string) $buttonLabel); ?></button>
</form>
<?php endif; ?>
<?php if (!empty($order['telefono_url'])): ?>

View File

@ -311,6 +311,41 @@ function cc_test_state_label(string $estado): string
};
}
function cc_test_row_background_style(string $estado): string
{
$color = match (cc_test_normalize_state($estado)) {
// Blanco
'POR LLAMAR', 'DEVOLVER LLAMADA' => '#ffffff',
// Amarillo
'OBSERVADO' => '#fff3cd',
// Verde claro
'SE ENVIO NUMERO DE CUENTA' => '#d1e7dd',
// Naranja
'CONFIRMADO CONTRAENTREGA' => '#ffb74d',
// Verde
'CONFIRMADO ENVIO' => '#b6f1c7',
// Rojo
'CANCELADO' => '#f8d7da',
// Plomo
'REPETIDO' => '#d6d8db',
default => '#ffffff',
};
// We set a CSS variable so we can paint the entire row (all <td> cells).
// Bootstrap/table styles often apply opaque backgrounds to <td>, which can hide
// the <tr> background. A CSS rule below will apply this variable to every cell.
return '--cc-row-bg:' . $color . ';';
}
if (!function_exists('cc_test_table_exists')) {
function cc_test_table_exists(PDO $pdo, string $tableName): bool
{

View File

@ -219,7 +219,7 @@ function drive_test_fetch_tracking(PDO $pdo, array $sourceKeys): array
cc_test_ensure_tracking_table($pdo);
$placeholders = implode(',', array_fill(0, count($sourceKeys), '?'));
$stmt = $pdo->prepare("SELECT source_key, estado, nota_seguimiento, user_id, direccion, referencia, agencia, sede_agencia, sede, ciudad, distrito, dni, observaciones, producto, cantidad, precio, confirmacion_producto, confirmacion_cantidad, confirmacion_precio, proxima_llamada_at, fecha_entrega_programada, numero_cuenta_enviado_at, ultima_gestion_at, updated_at FROM callcenter_test_tracking WHERE source_key IN ($placeholders)");
$stmt = $pdo->prepare("SELECT source_key, estado, nota_seguimiento, user_id, direccion, referencia, agencia, sede_agencia, sede, ciudad, distrito, dni, observaciones, producto, cantidad, precio, confirmacion_producto, confirmacion_cantidad, confirmacion_precio, confirmacion_producto_extra, confirmacion_cantidad_extra, confirmacion_precio_extra, proxima_llamada_at, fecha_entrega_programada, numero_cuenta_enviado_at, ultima_gestion_at, updated_at FROM callcenter_test_tracking WHERE source_key IN ($placeholders)");
$stmt->execute($sourceKeys);
$tracking = [];
@ -232,13 +232,14 @@ function drive_test_fetch_tracking(PDO $pdo, array $sourceKeys): array
function drive_test_merge_tracking(array $orders, array $tracking): array
{
$editableFields = ['direccion', 'referencia', 'agencia', 'sede_agencia', 'sede', 'ciudad', 'distrito', 'dni', 'observaciones', 'producto', 'cantidad', 'precio', 'confirmacion_producto', 'confirmacion_cantidad', 'confirmacion_precio'];
$editableFields = ['direccion', 'referencia', 'agencia', 'sede_agencia', 'sede', 'ciudad', 'distrito', 'dni', 'observaciones', 'producto', 'cantidad', 'precio', 'confirmacion_producto', 'confirmacion_cantidad', 'confirmacion_precio', 'confirmacion_producto_extra', 'confirmacion_cantidad_extra', 'confirmacion_precio_extra'];
foreach ($orders as &$order) {
$current = $tracking[$order['source_key']] ?? null;
$order['estado'] = $current['estado'] ?? 'POR LLAMAR';
$order['nota_seguimiento'] = $current['nota_seguimiento'] ?? '';
$order['user_id'] = array_key_exists('user_id', (array) $current) ? ($current['user_id'] !== null ? (int) $current['user_id'] : null) : null;
$userId = array_key_exists('user_id', (array) $current) ? $current['user_id'] : null;
$order['user_id'] = $userId !== null ? ((int) $userId > 0 ? (int) $userId : null) : null;
$order['seguimiento_actualizado'] = $current['updated_at'] ?? null;
$order['proxima_llamada_at'] = $current['proxima_llamada_at'] ?? null;
$order['fecha_entrega_programada'] = $current['fecha_entrega_programada'] ?? null;

View File

@ -247,6 +247,48 @@ include 'layout_header.php';
white-space: normal;
font-size: 0.82rem;
}
/* Contraentrega: icono para ver/subir verificación (foto + tracking) */
#pedidos-table .cc-verificacion-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
font-size: 1.1rem;
}
#pedidos-table .cc-verificacion-btn.cc-status-none {
background: #e9ecef;
color: #0d6efd;
border: 1px solid rgba(0,0,0,0.06);
}
#pedidos-table .cc-verificacion-btn.cc-status-pending {
background: #fff3cd;
color: #5f4700;
border: 1px solid rgba(0,0,0,0.06);
}
#pedidos-table .cc-verificacion-btn.cc-status-match {
background: #198754;
color: #fff;
border: 1px solid rgba(0,0,0,0.06);
}
.cc-verificacion-img-preview {
max-width: 100%;
max-height: 360px;
object-fit: contain;
background: #f8f9fa;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
}
.cc-inline-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
word-break: break-word;
}
</style>
<div class="card mb-4">
@ -317,7 +359,7 @@ include 'layout_header.php';
<table id="pedidos-table" class="table table-striped table-hover">
<thead>
<tr>
<th style="width: 35px;">ID</th>
<th style="width: 120px;">ID</th>
<th style="width: 120px;">Seguimiento</th>
<th style="width: 130px;">Paquete</th>
<th style="width: 180px;">Estado</th>
@ -346,7 +388,31 @@ include 'layout_header.php';
<tbody>
<?php foreach ($pedidos as $pedido): ?>
<tr>
<td><?php echo htmlspecialchars($pedido['id']); ?></td>
<?php
$ccVerifTracking = trim((string)($pedido['contraentrega_verificacion_tracking'] ?? ''));
$ccVerifImg = trim((string)($pedido['contraentrega_verificacion_imagen_path'] ?? ''));
$ccVerifReady = $ccVerifTracking !== '' && $ccVerifImg !== '';
$ccSeguimiento = trim((string)($pedido['seguimiento'] ?? ''));
$ccVerifMatch = $ccVerifReady && $ccSeguimiento !== '' && $ccSeguimiento === $ccVerifTracking;
$ccVerifBtnClass = $ccVerifMatch ? 'cc-status-match' : ($ccVerifReady ? 'cc-status-pending' : 'cc-status-none');
$ccVerifIcon = $ccVerifMatch ? '✅' : '📷';
?>
<td data-order="<?php echo (int)$pedido['id']; ?>">
<div class="cc-id-verif d-flex align-items-center gap-2">
<span class="cc-order-id"><?php echo htmlspecialchars($pedido['id']); ?></span>
<button type="button" class="btn btn-sm cc-verificacion-btn <?php echo $ccVerifBtnClass; ?>"
data-bs-toggle="modal" data-bs-target="#contraentregaVerificacionModal"
data-pedido-id="<?php echo htmlspecialchars((string)$pedido['id'], ENT_QUOTES, 'UTF-8'); ?>"
data-nombre-completo="<?php echo htmlspecialchars((string)($pedido['nombre_completo'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>"
data-verificacion-tracking="<?php echo htmlspecialchars($ccVerifTracking, ENT_QUOTES, 'UTF-8'); ?>"
data-verificacion-imagen="<?php echo htmlspecialchars($ccVerifImg, ENT_QUOTES, 'UTF-8'); ?>"
data-seguimiento="<?php echo htmlspecialchars($ccSeguimiento, ENT_QUOTES, 'UTF-8'); ?>"
aria-label="Verificación de contraentrega">
<?php echo $ccVerifIcon; ?>
</button>
</div>
</td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="seguimiento"><?php echo htmlspecialchars($pedido['seguimiento'] ?? ''); ?></td>
<td class="editable-paquete" data-id="<?php echo $pedido['id']; ?>" data-value="<?php echo htmlspecialchars($pedido['tipo_paquete'] ?? ''); ?>" style="cursor: pointer;">
<span class="badge" style="<?php echo getPaqueteStyle($pedido['tipo_paquete'] ?? ''); ?>">
@ -456,7 +522,291 @@ include 'layout_header.php';
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?><div class="modal fade" id="contraentregaVerificacionModal" tabindex="-1" aria-labelledby="contraentregaVerificacionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable" style="max-width: 95vw;">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contraentregaVerificacionModalLabel">Verificación Contraentrega (Foto + Tracking)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-7">
<div class="mb-2">
<div class="text-muted small">Pedido #<span id="ccVerifPedidoIdText"></span></div>
<div class="fw-semibold" id="ccVerifNombreDestinatario"></div>
</div>
<div class="mb-3">
<img id="ccVerifPreviewImg" class="cc-verificacion-img-preview" src="" alt="Imagen de verificación" style="display:none;">
<div id="ccVerifNoImg" class="text-muted" style="display:none;">Sin imagen cargada todavía.</div>
</div>
<div class="alert alert-info mb-0" role="alert" style="padding: 0.6rem 0.85rem;">
<div class="small">
<strong>Tip:</strong> La otra persona debe revisar que el <em>tracking</em> coincida con el <em>nombre del destinatario</em> que aparece en la foto.
</div>
</div>
</div>
<div class="col-md-5">
<input type="hidden" id="ccVerifPedidoIdHidden" value="">
<div id="ccVerifEditSection" style="<?php echo ($user_role === 'Logistica') ? 'display:none;' : ''; ?>">
<div class="mb-3">
<label for="ccVerifImageInput" class="form-label">Subir imagen del paquete</label>
<input type="file" class="form-control" id="ccVerifImageInput" accept="image/*">
<div class="form-text">Foto del estado del paquete donde se vea el tracking y el nombre.</div>
</div>
<div class="mb-3">
<label for="ccVerifTrackingInput" class="form-label">Tracking (código)</label>
<input type="text" class="form-control" id="ccVerifTrackingInput" placeholder="Ej: 123456...">
</div>
<button type="button" class="btn btn-primary w-100" id="ccVerifSaveBtn">Guardar verificación</button>
</div>
<div class="mb-3">
<label class="form-label">Tracking guardado (para copiar a “Seguimiento”)</label>
<div class="p-2 rounded bg-light">
<span id="ccVerifTrackingSaved" class="cc-inline-code text-break"></span>
</div>
</div>
<div class="mb-3">
<label class="form-label">Seguimiento actual (campo editable en la tabla)</label>
<div class="p-2 rounded bg-light">
<span id="ccVerifSeguimientoActual" class="cc-inline-code text-break"></span>
</div>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-success" id="ccVerifCopyBtn" disabled>Copiar tracking</button>
<div class="text-muted small" id="ccVerifCopyHint">Luego pegalo en la celda “Seguimiento”.</div>
</div>
<div id="ccVerifErrorBox" class="alert alert-danger mt-3 d-none" role="alert"></div>
<div id="ccVerifSuccessBox" class="alert alert-success mt-3 d-none" role="alert"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
<script>
(() => {
const modalEl = document.getElementById('contraentregaVerificacionModal');
if (!modalEl) return;
const pedidoIdText = document.getElementById('ccVerifPedidoIdText');
const pedidoIdHidden = document.getElementById('ccVerifPedidoIdHidden');
const nombreEl = document.getElementById('ccVerifNombreDestinatario');
const previewImg = document.getElementById('ccVerifPreviewImg');
const noImgEl = document.getElementById('ccVerifNoImg');
const trackingSavedEl = document.getElementById('ccVerifTrackingSaved');
const seguimientoActualEl = document.getElementById('ccVerifSeguimientoActual');
const editSection = document.getElementById('ccVerifEditSection');
const imageInput = document.getElementById('ccVerifImageInput');
const trackingInput = document.getElementById('ccVerifTrackingInput');
const saveBtn = document.getElementById('ccVerifSaveBtn');
const copyBtn = document.getElementById('ccVerifCopyBtn');
const errorBox = document.getElementById('ccVerifErrorBox');
const successBox = document.getElementById('ccVerifSuccessBox');
function showBox(boxEl, msg) {
boxEl.textContent = msg;
boxEl.classList.remove('d-none');
}
function hideBoxes() {
errorBox.classList.add('d-none');
errorBox.textContent = '';
successBox.classList.add('d-none');
successBox.textContent = '';
}
function setPreviewImage(imgPath) {
if (imgPath) {
previewImg.src = imgPath;
previewImg.style.display = 'block';
noImgEl.style.display = 'none';
} else {
previewImg.src = '';
previewImg.style.display = 'none';
noImgEl.style.display = 'block';
}
}
modalEl.addEventListener('show.bs.modal', (event) => {
const btn = event.relatedTarget;
if (!btn) return;
hideBoxes();
const pedidoId = btn.getAttribute('data-pedido-id') || '';
const nombre = btn.getAttribute('data-nombre-completo') || '';
const verifTracking = btn.getAttribute('data-verificacion-tracking') || '';
const verifImg = btn.getAttribute('data-verificacion-imagen') || '';
const seguimiento = btn.getAttribute('data-seguimiento') || '';
pedidoIdHidden.value = pedidoId;
if (pedidoIdText) pedidoIdText.textContent = pedidoId;
if (nombreEl) nombreEl.textContent = nombre;
trackingSavedEl.textContent = verifTracking || 'N/A';
seguimientoActualEl.textContent = seguimiento || 'N/A';
if (editSection && editSection.style.display !== 'none') {
// Solo precargar tracking si hay editor visible (evita confusiones a Logistica)
if (trackingInput) trackingInput.value = verifTracking || '';
}
setPreviewImage(verifImg);
// Copy button enabled only when there is a saved tracking and an image
copyBtn.disabled = !(verifTracking && verifImg);
// Reset file input
if (imageInput) {
imageInput.value = '';
}
});
if (imageInput) {
imageInput.addEventListener('change', () => {
const file = imageInput.files && imageInput.files[0] ? imageInput.files[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
previewImg.style.display = 'block';
if (noImgEl) noImgEl.style.display = 'none';
};
reader.readAsDataURL(file);
});
}
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
hideBoxes();
const pedidoId = pedidoIdHidden.value;
const tracking = (trackingInput && trackingInput.value ? trackingInput.value : '').trim();
const file = imageInput && imageInput.files && imageInput.files[0] ? imageInput.files[0] : null;
if (!tracking) {
showBox(errorBox, 'Ingresá el tracking (código) para guardar la verificación.');
return;
}
if (!file) {
showBox(errorBox, 'Seleccioná una imagen para guardar la verificación.');
return;
}
saveBtn.disabled = true;
const oldText = saveBtn.textContent;
saveBtn.textContent = 'Guardando...';
try {
const fd = new FormData();
fd.append('pedido_id', pedidoId);
fd.append('tracking_verificacion', tracking);
fd.append('imagen', file);
const resp = await fetch('save_contraentrega_verificacion.php', {
method: 'POST',
body: fd
});
const data = await resp.json();
if (!data || !data.success) {
showBox(errorBox, (data && data.error) ? data.error : 'No se pudo guardar la verificación.');
return;
}
showBox(successBox, 'Verificación guardada ✅');
const modalInstance = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modalInstance.hide();
// Reload to update icon/status
window.location.href = window.location.pathname + window.location.search;
} catch (e) {
console.error(e);
showBox(errorBox, 'Error de conexión al guardar.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = oldText;
}
});
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.left = '-1000px';
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch (err) {
ok = false;
}
document.body.removeChild(ta);
return ok;
}
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
hideBoxes();
const text = (trackingSavedEl && trackingSavedEl.textContent ? trackingSavedEl.textContent : '').trim();
if (!text) {
showBox(errorBox, 'No hay tracking guardado para copiar.');
return;
}
try {
const ok = await copyTextToClipboard(text);
if (!ok) {
showBox(errorBox, 'No se pudo copiar al portapapeles.');
return;
}
showBox(successBox, 'Copiado ✅ Pegalo en la celda “Seguimiento”.');
} catch (e) {
console.error(e);
showBox(errorBox, 'No se pudo copiar al portapapeles.');
}
});
}
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -794,7 +1144,36 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) {
console.error('Error:', data.error);
// Optionally revert the change and show an error message
cell.textContent = originalContent;
cell.textContent = originalContent;
return;
}
// Si se actualizó "Seguimiento", actualizamos el icono de verificación en la misma fila
if (field === 'seguimiento') {
const row = cell.closest('tr');
if (row) {
const btn = row.querySelector('button.cc-verificacion-btn');
if (btn) {
const verifTracking = (btn.getAttribute('data-verificacion-tracking') || '').trim();
const verifImg = (btn.getAttribute('data-verificacion-imagen') || '').trim();
const hasVerif = verifTracking !== '' && verifImg !== '';
const match = hasVerif && (newValue === verifTracking);
btn.classList.remove('cc-status-none', 'cc-status-pending', 'cc-status-match');
if (!hasVerif) {
btn.classList.add('cc-status-none');
} else if (match) {
btn.classList.add('cc-status-match');
} else {
btn.classList.add('cc-status-pending');
}
btn.textContent = match ? '✅' : '📷';
btn.setAttribute('data-seguimiento', newValue);
}
}
}
})
.catch(error => {

View File

@ -0,0 +1,175 @@
<?php
session_start();
require_once 'db/config.php';
header('Content-Type: application/json; charset=utf-8');
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Acceso no autorizado.']);
exit;
}
$pdo = db();
function cc_column_exists(PDO $pdo, string $column): bool {
$stmt = $pdo->prepare('SHOW COLUMNS FROM pedidos LIKE ?');
$stmt->execute([$column]);
return (bool) $stmt->fetch();
}
function cc_ensure_contraentrega_verificacion_columns(PDO $pdo): void {
$columns = [
'contraentrega_verificacion_imagen_path' => 'VARCHAR(500) NULL',
'contraentrega_verificacion_tracking' => 'VARCHAR(255) NULL',
'contraentrega_verificacion_user_id' => 'INT NULL',
'contraentrega_verificacion_created_at' => 'TIMESTAMP NULL DEFAULT NULL',
];
foreach ($columns as $col => $def) {
if (!cc_column_exists($pdo, $col)) {
// DDL: safe because we only use constant column names/definitions.
$pdo->exec('ALTER TABLE pedidos ADD COLUMN ' . $col . ' ' . $def);
}
}
}
$user_id = (int) ($_SESSION['user_id'] ?? 0);
$user_role = (string) ($_SESSION['user_role'] ?? 'Asesor');
$pedido_id = (int) (($_POST['pedido_id'] ?? '') !== '' ? $_POST['pedido_id'] : 0);
$tracking_verificacion = trim((string) ($_POST['tracking_verificacion'] ?? ''));
if ($pedido_id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'pedido_id es obligatorio.']);
exit;
}
if ($tracking_verificacion === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'tracking_verificacion es obligatorio.']);
exit;
}
// Permission check
$stmtPedido = $pdo->prepare('SELECT id, asesor_id FROM pedidos WHERE id = ? LIMIT 1');
$stmtPedido->execute([$pedido_id]);
$pedido = $stmtPedido->fetch(PDO::FETCH_ASSOC);
if (!$pedido) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Pedido no encontrado.']);
exit;
}
if ($user_role === 'Asesor' && (int) ($pedido['asesor_id'] ?? 0) !== $user_id) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'No autorizado para gestionar este pedido.']);
exit;
}
if (!isset($_FILES['imagen']) || !is_array($_FILES['imagen'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'imagen es obligatoria.']);
exit;
}
$imagen = $_FILES['imagen'];
if (($imagen['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Error al subir la imagen.']);
exit;
}
$maxSize = 8 * 1024 * 1024; // 8MB
if (!isset($imagen['size']) || (int)$imagen['size'] <= 0 || (int)$imagen['size'] > $maxSize) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'La imagen excede el tamaño máximo permitido (8MB).']);
exit;
}
$tmpName = (string) ($imagen['tmp_name'] ?? '');
if ($tmpName === '' || !file_exists($tmpName)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Temp file inválido.']);
exit;
}
$imgInfo = @getimagesize($tmpName);
if ($imgInfo === false || empty($imgInfo['mime'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'El archivo no parece ser una imagen válida.']);
exit;
}
$mime = (string) $imgInfo['mime'];
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Formato no permitido. Use JPG, PNG o WEBP.']);
exit;
}
$ext = $allowed[$mime];
$uploadDir = __DIR__ . '/assets/uploads/contraentrega_verificaciones';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$filename = sprintf(
'contraentrega_verificacion_%d_%d_%d_%s.%s',
$pedido_id,
$user_id,
time(),
bin2hex(random_bytes(4)),
$ext
);
$targetPath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($tmpName, $targetPath)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'No se pudo guardar la imagen en el servidor.']);
exit;
}
$relativePath = 'assets/uploads/contraentrega_verificaciones/' . $filename;
try {
cc_ensure_contraentrega_verificacion_columns($pdo);
$stmt = $pdo->prepare('UPDATE pedidos
SET contraentrega_verificacion_imagen_path = ?,
contraentrega_verificacion_tracking = ?,
contraentrega_verificacion_user_id = ?,
contraentrega_verificacion_created_at = NOW()
WHERE id = ?');
$stmt->execute([$relativePath, $tracking_verificacion, $user_id, $pedido_id]);
if ($stmt->rowCount() <= 0) {
// Still treat as success because UPDATE might not change values
echo json_encode(['success' => true, 'info' => 'La verificación se guardó, pero no hubo cambios.']);
exit;
}
echo json_encode(['success' => true, 'imagen' => $relativePath, 'tracking_verificacion' => $tracking_verificacion]);
} catch (Throwable $e) {
error_log('save_contraentrega_verificacion.php: ' . $e->getMessage());
http_response_code(500);
$msg = $e->getMessage();
if (is_string($msg) && strlen($msg) > 300) {
$msg = substr($msg, 0, 300) . '...';
}
echo json_encode(['success' => false, 'error' => 'Error al guardar la verificación. ' . $msg]);
}