Autosave: 20260721-185118

This commit is contained in:
Flatlogic Bot 2026-07-21 18:49:58 +00:00
parent 33ea21ca77
commit 0897d311c3
5 changed files with 761 additions and 26 deletions

View File

@ -445,3 +445,109 @@ h1, .h1 {
.cc-callcenter-row:hover td {
background-color: var(--cc-row-bg, #ffffff) !important;
}
/* Call center KPI dashboard */
.cc-kpi-panel {
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
border: 1px solid rgba(13, 110, 253, .08) !important;
}
.cc-kpi-pill-group {
align-items: stretch;
}
.cc-kpi-pill {
min-width: 10.5rem;
padding: .85rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(15, 23, 42, .08);
background: #ffffff;
box-shadow: 0 .7rem 1.35rem rgba(15, 23, 42, .05);
display: flex;
flex-direction: column;
gap: .15rem;
color: #111827;
}
.cc-kpi-label {
font-size: .72rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
line-height: 1.05;
opacity: .72;
}
.cc-kpi-value {
font-size: 1.35rem;
font-weight: 800;
line-height: 1.05;
}
.cc-kpi-pill-primary {
background: linear-gradient(135deg, rgba(13, 110, 253, .14), rgba(13, 110, 253, .04));
}
.cc-kpi-pill-success {
background: linear-gradient(135deg, rgba(25, 135, 84, .14), rgba(25, 135, 84, .04));
}
.cc-kpi-pill-warning {
background: linear-gradient(135deg, rgba(255, 193, 7, .18), rgba(255, 193, 7, .06));
}
.cc-kpi-pill-info {
background: linear-gradient(135deg, rgba(13, 202, 240, .14), rgba(13, 202, 240, .04));
}
.cc-kpi-pill-danger {
background: linear-gradient(135deg, rgba(220, 53, 69, .14), rgba(220, 53, 69, .04));
}
.cc-kpi-pill-dark {
background: linear-gradient(135deg, rgba(33, 37, 41, .10), rgba(33, 37, 41, .03));
}
.cc-kpi-pill-secondary {
background: linear-gradient(135deg, rgba(108, 117, 125, .12), rgba(108, 117, 125, .04));
}
.cc-kpi-chart-card {
border-radius: 1.2rem;
overflow: hidden;
background: #ffffff;
}
.cc-kpi-chart-wrap {
position: relative;
min-height: 320px;
}
.cc-kpi-chart-wrap canvas {
width: 100% !important;
height: 320px !important;
}
.cc-kpi-footnote {
font-size: .85rem;
color: #6c757d;
}
@media (max-width: 1199.98px) {
.cc-kpi-pill {
min-width: 9.5rem;
}
.cc-kpi-chart-wrap,
.cc-kpi-chart-wrap canvas {
min-height: 280px;
height: 280px !important;
}
}
@media (max-width: 575.98px) {
.cc-kpi-pill {
min-width: calc(50% - .5rem);
flex: 1 1 calc(50% - .5rem);
}
}

View File

@ -182,8 +182,16 @@ function cc_test_order_time(array $order): int
function cc_test_import_time(array $order): int
{
if (!empty($order['is_agregado'])) {
return (int) ($order['id'] ?? 0);
foreach (['drive_imported_at', 'first_seen_at'] as $field) {
$date = cc_test_parse_datetime($order[$field] ?? null);
if ($date) {
return $date->getTimestamp();
}
}
$sourceRow = (int) ($order['source_row'] ?? 0);
if ($sourceRow > 0) {
return $sourceRow;
}
$importRaw = trim((string) ($order['import_id'] ?? ''));
@ -201,7 +209,11 @@ function cc_test_import_time(array $order): int
$codigoRaw = trim((string) ($order['codigo'] ?? ''));
$digits = preg_replace('/\D+/', '', $codigoRaw);
return $digits !== '' ? (int) $digits : 0;
if ($digits !== '') {
return (int) $digits;
}
return (int) ($order['id'] ?? 0);
}
function cc_test_followup_semaforo(array $order): ?array
{
@ -237,7 +249,7 @@ function cc_test_panel_orders_signature(array $orders): string
$view = $_GET['view'] ?? 'pendientes_hoy';
$allowedViews = [
'pendientes_hoy' => 'Bandeja principal',
'nuevos_hoy' => 'Nuevos de hoy',
'nuevos_hoy' => 'Pedidos asignados hoy',
'confirmados' => 'Confirmados',
'seguimiento' => 'Seguimiento',
'promo_final' => 'Promo Final',
@ -410,7 +422,7 @@ try {
foreach ($orders as &$order) {
$order['estado'] = cc_test_normalize_state((string) ($order['estado'] ?? ''));
$order['total_llamadas'] = (int) ($callCounts[$order['source_key']] ?? 0);
$importDate = cc_test_parse_datetime($order['import_id'] ?? null);
$assignedDate = cc_test_parse_datetime($order['assigned_at'] ?? null);
$proximaDate = cc_test_parse_datetime($order['proxima_llamada_at'] ?? null);
$followupDayInfo = cc_test_followup_day_info($order);
$order['seguimiento_dia_info'] = $followupDayInfo;
@ -424,7 +436,7 @@ try {
: null;
$order['pendiente_logistica'] = $order['pendiente_logistica_destino'] !== null;
$order['es_nuevo_hoy'] = $importDate ? $importDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_nuevo_hoy'] = $assignedDate ? $assignedDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_pendiente_hoy'] = !$order['es_promo_final'] && (in_array($order['estado'], $openStates, true) || $order['pendiente_logistica']);
$order['es_cerrado'] = in_array($order['estado'], $closedStates, true);
$order['assessor_key'] = cc_test_find_assessor_key_by_user_id(isset($order['user_id']) ? (int) $order['user_id'] : null, $assessors);
@ -462,6 +474,23 @@ try {
}
unset($order);
$performanceDashboard = cc_test_build_performance_dashboard($orders, $assessors, 7);
$performanceTitle = $isAdmin
? ($selectedAssessorFilterLabel !== '' ? 'KPI diario · ' . $selectedAssessorFilterLabel : 'KPI diario por asesora')
: 'Mi KPI diario';
$performanceSubtitle = $isAdmin
? 'Confirmación = pedidos asignados hoy confirmados hoy. Recuperación = pedidos de días anteriores confirmados hoy.'
: 'Tus KPIs de hoy se calculan sobre tus pedidos asignados. Confirmación y recuperación se separan para ver tu productividad real.';
$performanceDashboardHtml = cc_test_render_performance_dashboard($performanceDashboard, [
'title' => $performanceTitle,
'subtitle' => $performanceSubtitle,
'chart1_title' => $isAdmin ? 'Rendimiento por asesora hoy' : 'Mi rendimiento de hoy',
'chart1_note' => 'Asignados, confirmados nuevos, recuperados y cancelados de hoy.',
'chart2_title' => $isAdmin ? 'Evolución de los últimos 7 días' : 'Tu evolución de los últimos 7 días',
'chart2_note' => 'La línea de confirmados separa pedidos nuevos y recuperados.',
'footnote' => 'Seguimiento abierto = pedidos en SE ENVIO NUMERO DE CUENTA que todavía no pasan a Promo Final.',
]);
$panelSignature = cc_test_panel_orders_signature($orders);
$visibleOrders = array_values(array_filter($orders, static function (array $order) use ($view, $selectedAssessorFilter): bool {
@ -482,15 +511,23 @@ try {
}));
usort($visibleOrders, static function (array $a, array $b) use ($view, $storeKey): int {
$aAgregado = !empty($a['is_agregado']);
$bAgregado = !empty($b['is_agregado']);
if ($aAgregado !== $bAgregado) {
return $aAgregado ? -1 : 1;
}
$aImport = cc_test_import_time($a);
$bImport = cc_test_import_time($b);
if ($view === 'nuevos_hoy') {
$aAssigned = cc_test_parse_datetime($a['assigned_at'] ?? null);
$bAssigned = cc_test_parse_datetime($b['assigned_at'] ?? null);
if ($aAssigned && $bAssigned && $aAssigned != $bAssigned) {
return $bAssigned <=> $aAssigned;
}
if ($aAssigned && !$bAssigned) {
return -1;
}
if (!$aAssigned && $bAssigned) {
return 1;
}
}
if ($view === 'promo_final') {
$aDays = (int) ($a['dias_seguimiento'] ?? 0);
$bDays = (int) ($b['dias_seguimiento'] ?? 0);
@ -770,6 +807,9 @@ require_once 'layout_header.php';
<?php echo htmlspecialchars($errorMessage); ?>
</section>
<?php else: ?>
<?php echo $performanceDashboardHtml ?? ''; ?>
<section class="row g-3 mb-4">
<div class="col-md-6 col-xl-2">
<a href="<?php echo htmlspecialchars(cc_test_build_url('call_center_pro.php', array_merge($callCenterParams, ['view' => 'pendientes_hoy']))); ?>" class="text-decoration-none">
@ -785,7 +825,7 @@ require_once 'layout_header.php';
<a href="<?php echo htmlspecialchars(cc_test_build_url('call_center_pro.php', array_merge($callCenterParams, ['view' => 'nuevos_hoy']))); ?>" class="text-decoration-none">
<article class="card border-0 shadow-sm h-100 <?php echo $view === 'nuevos_hoy' ? 'bg-primary-subtle border border-primary' : 'bg-white'; ?>">
<div class="card-body">
<div class="small text-uppercase text-muted mb-2">Nuevos de hoy</div>
<div class="small text-uppercase text-muted mb-2">Asignados hoy</div>
<div class="display-6 fw-bold mb-0"><?php echo (int) $stats['nuevos_hoy']; ?></div>
</div>
</article>

View File

@ -117,8 +117,16 @@ function cc_test_order_time(array $order): int
function cc_test_import_time(array $order): int
{
if (!empty($order['is_agregado'])) {
return (int) ($order['id'] ?? 0);
foreach (['drive_imported_at', 'first_seen_at'] as $field) {
$date = cc_test_parse_datetime($order[$field] ?? null);
if ($date) {
return $date->getTimestamp();
}
}
$sourceRow = (int) ($order['source_row'] ?? 0);
if ($sourceRow > 0) {
return $sourceRow;
}
$importRaw = trim((string) ($order['import_id'] ?? ''));
@ -136,7 +144,11 @@ function cc_test_import_time(array $order): int
$codigoRaw = trim((string) ($order['codigo'] ?? ''));
$digits = preg_replace('/\D+/', '', $codigoRaw);
return $digits !== '' ? (int) $digits : 0;
if ($digits !== '') {
return (int) $digits;
}
return (int) ($order['id'] ?? 0);
}
function cc_test_followup_semaforo(array $order): ?array
{
@ -610,7 +622,7 @@ try {
foreach ($orders as &$order) {
$order['estado'] = cc_test_normalize_state((string) ($order['estado'] ?? ''));
$order['total_llamadas'] = (int) ($callCounts[$order['source_key']] ?? 0);
$importDate = cc_test_parse_datetime($order['import_id'] ?? null);
$firstSeenDate = cc_test_parse_datetime($order['first_seen_at'] ?? null);
$proximaDate = cc_test_parse_datetime($order['proxima_llamada_at'] ?? null);
$followupDayInfo = cc_test_followup_day_info($order);
$order['seguimiento_dia_info'] = $followupDayInfo;
@ -624,7 +636,7 @@ try {
: null;
$order['pendiente_logistica'] = $order['pendiente_logistica_destino'] !== null;
$order['es_nuevo_hoy'] = $importDate ? $importDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_nuevo_hoy'] = $firstSeenDate ? $firstSeenDate->format('Y-m-d') === $todayStart->format('Y-m-d') : false;
$order['es_pendiente_hoy'] = !$order['es_promo_final'] && (in_array($order['estado'], $openStates, true) || $order['pendiente_logistica']);
$order['es_cerrado'] = in_array($order['estado'], $closedStates, true);
@ -663,6 +675,17 @@ try {
}
unset($order);
$performanceDashboard = cc_test_build_performance_dashboard($orders, $assessors, 7);
$performanceDashboardHtml = cc_test_render_performance_dashboard($performanceDashboard, [
'title' => 'KPI diario por asesora',
'subtitle' => 'Confirmación = pedidos asignados hoy confirmados hoy. Recuperación = pedidos de días anteriores confirmados hoy.',
'chart1_title' => 'Rendimiento por asesora hoy',
'chart1_note' => 'Asignados, confirmados nuevos, recuperados y cancelados de hoy.',
'chart2_title' => 'Evolución de los últimos 7 días',
'chart2_note' => 'La línea de confirmados separa pedidos nuevos y recuperados.',
'footnote' => 'Seguimiento abierto = pedidos en SE ENVIO NUMERO DE CUENTA que todavía no pasan a Promo Final.',
]);
$orderedAssessorKeys = array_values(array_filter(cc_test_allowed_module_user_keys(), static function (string $key) use ($assessors): bool {
return isset($assessors[$key]);
}));
@ -744,12 +767,6 @@ try {
}));
usort($visibleOrders, static function (array $a, array $b) use ($view, $storeKey): int {
$aAgregado = !empty($a['is_agregado']);
$bAgregado = !empty($b['is_agregado']);
if ($aAgregado !== $bAgregado) {
return $aAgregado ? -1 : 1;
}
$aImport = cc_test_import_time($a);
$bImport = cc_test_import_time($b);
@ -764,6 +781,20 @@ try {
}
}
if ($view === 'nuevos_hoy') {
$aNew = cc_test_parse_datetime($a['first_seen_at'] ?? null);
$bNew = cc_test_parse_datetime($b['first_seen_at'] ?? null);
if ($aNew && $bNew && $aNew != $bNew) {
return $bNew <=> $aNew;
}
if ($aNew && !$bNew) {
return -1;
}
if (!$aNew && $bNew) {
return 1;
}
}
if ($view === "pendientes_hoy") {
$aPendientePromoFinal = !empty($a['promo_final_pendiente']);
$bPendientePromoFinal = !empty($b['promo_final_pendiente']);
@ -1075,6 +1106,9 @@ require_once 'layout_header.php';
<?php echo htmlspecialchars($errorMessage); ?>
</section>
<?php else: ?>
<?php echo $performanceDashboardHtml ?? ''; ?>
<section class="mb-4">
<div class="card border-0 shadow-sm">
<div class="card-body">

View File

@ -620,6 +620,561 @@ function cc_test_followup_day_info(array $order): ?array
];
}
function cc_test_dashboard_date_key(?string $value): ?string
{
$date = cc_test_followup_parse_datetime($value);
return $date instanceof DateTimeImmutable ? $date->format('Y-m-d') : null;
}
function cc_test_dashboard_origin_datetime(array $order): ?DateTimeImmutable
{
foreach (['first_seen_at', 'drive_imported_at', 'import_id', 'assigned_at', 'updated_at'] as $field) {
$date = cc_test_followup_parse_datetime($order[$field] ?? null);
if ($date instanceof DateTimeImmutable) {
return $date;
}
}
return null;
}
function cc_test_dashboard_action_datetime(array $order): ?DateTimeImmutable
{
$estado = cc_test_normalize_state((string) ($order['estado'] ?? ''));
$fields = match ($estado) {
'CONFIRMADO CONTRAENTREGA' => ['ruta_contraentrega_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'],
'CONFIRMADO ENVIO' => ['pedido_rotulado_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'],
'CANCELADO' => ['cancelado_evidencia_subido_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'],
'SE ENVIO NUMERO DE CUENTA' => ['numero_cuenta_enviado_at', 'updated_at', 'ultima_gestion_at', 'assigned_at'],
default => ['updated_at', 'ultima_gestion_at', 'assigned_at'],
};
foreach ($fields as $field) {
$date = cc_test_followup_parse_datetime($order[$field] ?? null);
if ($date instanceof DateTimeImmutable) {
return $date;
}
}
return null;
}
function cc_test_dashboard_bucket_key(array $order, array $assessors, bool $includeUnassigned = true): string
{
$assessorKey = trim((string) ($order['assessor_key'] ?? ''));
if ($assessorKey !== '') {
return $assessorKey;
}
$userId = (int) ($order['user_id'] ?? 0);
if ($userId > 0) {
foreach ($assessors as $key => $assessor) {
if ((int) ($assessor['id'] ?? 0) === $userId) {
return (string) $key;
}
}
}
return $includeUnassigned ? 'SIN_ASIGNAR' : '';
}
function cc_test_dashboard_bucket_label(string $bucketKey, array $assessors): string
{
if ($bucketKey === '' || $bucketKey === 'SIN_ASIGNAR') {
return 'Sin asignar';
}
return (string) ($assessors[$bucketKey]['label'] ?? $bucketKey);
}
function cc_test_build_performance_dashboard(array $orders, array $assessors, int $trendDays = 7): array
{
$trendDays = max(1, $trendDays);
$today = new DateTimeImmutable('today');
$todayKey = $today->format('Y-m-d');
$trend = [];
$trendLabels = [];
for ($i = $trendDays - 1; $i >= 0; $i--) {
$day = $today->sub(new DateInterval('P' . $i . 'D'));
$dayKey = $day->format('Y-m-d');
$trend[$dayKey] = [
'label' => $day->format('d/m'),
'assigned_today' => 0,
'confirmed_new' => 0,
'recovered' => 0,
'canceled' => 0,
];
$trendLabels[] = $day->format('d/m');
}
$orderedAssessorKeys = array_values(array_filter(cc_test_allowed_module_user_keys(), static function (string $key) use ($assessors): bool {
return isset($assessors[$key]);
}));
if (empty($orderedAssessorKeys)) {
$orderedAssessorKeys = array_keys($assessors);
}
$bucketStats = [];
foreach ($orderedAssessorKeys as $assessorKey) {
$bucketStats[$assessorKey] = [
'label' => cc_test_dashboard_bucket_label($assessorKey, $assessors),
'assigned_today' => 0,
'confirmed_new' => 0,
'recovered' => 0,
'canceled' => 0,
'followup_open' => 0,
];
}
$summary = [
'scope_total' => 0,
'assigned_today' => 0,
'confirmed_today_new' => 0,
'confirmed_today_recovered' => 0,
'confirmed_today_total' => 0,
'canceled_today' => 0,
'followup_open' => 0,
'confirm_rate_today' => 0.0,
'recovery_rate_today' => 0.0,
];
$hasUnassignedBucket = false;
foreach ($orders as $order) {
$summary['scope_total']++;
$bucketKey = cc_test_dashboard_bucket_key($order, $assessors, true);
if ($bucketKey === '') {
continue;
}
if ($bucketKey === 'SIN_ASIGNAR') {
$hasUnassignedBucket = true;
}
if (!isset($bucketStats[$bucketKey])) {
$bucketStats[$bucketKey] = [
'label' => cc_test_dashboard_bucket_label($bucketKey, $assessors),
'assigned_today' => 0,
'confirmed_new' => 0,
'recovered' => 0,
'canceled' => 0,
'followup_open' => 0,
];
}
$originDate = cc_test_dashboard_origin_datetime($order);
$originKey = $originDate ? $originDate->format('Y-m-d') : null;
$assignedDate = cc_test_followup_parse_datetime($order['assigned_at'] ?? null);
$assignedKey = $assignedDate ? $assignedDate->format('Y-m-d') : null;
$actionDate = cc_test_dashboard_action_datetime($order);
$actionKey = $actionDate ? $actionDate->format('Y-m-d') : null;
$estado = cc_test_normalize_state((string) ($order['estado'] ?? ''));
$followupInfo = cc_test_followup_day_info($order);
$isPromoFinal = !empty($followupInfo['is_promo_final']) && !empty($order['promo_final_evidence_path']);
$isConfirmed = in_array($estado, cc_test_confirmed_states(), true);
$isCanceled = $estado === 'CANCELADO';
$isFollowupOpen = $estado === 'SE ENVIO NUMERO DE CUENTA' && !$isPromoFinal;
if ($assignedKey === $todayKey) {
$bucketStats[$bucketKey]['assigned_today']++;
$summary['assigned_today']++;
if (isset($trend[$assignedKey])) {
$trend[$assignedKey]['assigned_today']++;
}
}
if ($isFollowupOpen) {
$bucketStats[$bucketKey]['followup_open']++;
$summary['followup_open']++;
}
if ($isConfirmed && $actionKey === $todayKey) {
if ($originKey !== null && $originKey < $todayKey) {
$bucketStats[$bucketKey]['recovered']++;
$summary['confirmed_today_recovered']++;
} else {
$bucketStats[$bucketKey]['confirmed_new']++;
$summary['confirmed_today_new']++;
}
if (isset($trend[$actionKey])) {
if ($originKey !== null && $originKey < $actionKey) {
$trend[$actionKey]['recovered']++;
} else {
$trend[$actionKey]['confirmed_new']++;
}
}
}
if ($isCanceled && $actionKey === $todayKey) {
$bucketStats[$bucketKey]['canceled']++;
$summary['canceled_today']++;
if (isset($trend[$actionKey])) {
$trend[$actionKey]['canceled']++;
}
}
}
$summary['confirmed_today_total'] = $summary['confirmed_today_new'] + $summary['confirmed_today_recovered'];
$summary['confirm_rate_today'] = $summary['assigned_today'] > 0
? round(($summary['confirmed_today_new'] / $summary['assigned_today']) * 100, 1)
: 0.0;
$summary['recovery_rate_today'] = $summary['confirmed_today_total'] > 0
? round(($summary['confirmed_today_recovered'] / $summary['confirmed_today_total']) * 100, 1)
: 0.0;
$advisorLabels = [];
$advisorAssigned = [];
$advisorConfirmedNew = [];
$advisorRecovered = [];
$advisorCanceled = [];
$advisorFollowup = [];
$finalBucketKeys = $orderedAssessorKeys;
foreach (array_keys($bucketStats) as $bucketKey) {
if (!in_array($bucketKey, $finalBucketKeys, true) && $bucketKey !== 'SIN_ASIGNAR') {
$finalBucketKeys[] = $bucketKey;
}
}
if ($hasUnassignedBucket) {
$finalBucketKeys[] = 'SIN_ASIGNAR';
}
foreach ($finalBucketKeys as $bucketKey) {
if (!isset($bucketStats[$bucketKey])) {
continue;
}
$bucket = $bucketStats[$bucketKey];
$advisorLabels[] = $bucket['label'];
$advisorAssigned[] = (int) $bucket['assigned_today'];
$advisorConfirmedNew[] = (int) $bucket['confirmed_new'];
$advisorRecovered[] = (int) $bucket['recovered'];
$advisorCanceled[] = (int) $bucket['canceled'];
$advisorFollowup[] = (int) $bucket['followup_open'];
}
if (empty($advisorLabels)) {
$advisorLabels = ['Sin datos'];
$advisorAssigned = [0];
$advisorConfirmedNew = [0];
$advisorRecovered = [0];
$advisorCanceled = [0];
$advisorFollowup = [0];
}
$trendAssigned = [];
$trendConfirmedNew = [];
$trendRecovered = [];
$trendCanceled = [];
foreach (array_keys($trend) as $dayKey) {
$trendAssigned[] = (int) $trend[$dayKey]['assigned_today'];
$trendConfirmedNew[] = (int) $trend[$dayKey]['confirmed_new'];
$trendRecovered[] = (int) $trend[$dayKey]['recovered'];
$trendCanceled[] = (int) $trend[$dayKey]['canceled'];
}
$advisorCount = 0;
foreach ($advisorLabels as $advisorLabel) {
if ($advisorLabel !== 'Sin datos' && $advisorLabel !== 'Sin asignar') {
$advisorCount++;
}
}
return [
'summary' => $summary,
'advisor_count' => $advisorCount,
'advisor_chart' => [
'labels' => $advisorLabels,
'datasets' => [
[
'label' => 'Asignados hoy',
'data' => $advisorAssigned,
'backgroundColor' => 'rgba(13, 110, 253, 0.75)',
'borderColor' => 'rgba(13, 110, 253, 1)',
'borderWidth' => 1,
'borderRadius' => 6,
'maxBarThickness' => 22,
],
[
'label' => 'Confirmados nuevos hoy',
'data' => $advisorConfirmedNew,
'backgroundColor' => 'rgba(25, 135, 84, 0.78)',
'borderColor' => 'rgba(25, 135, 84, 1)',
'borderWidth' => 1,
'borderRadius' => 6,
'maxBarThickness' => 22,
],
[
'label' => 'Recuperados de otros días',
'data' => $advisorRecovered,
'backgroundColor' => 'rgba(255, 193, 7, 0.82)',
'borderColor' => 'rgba(245, 158, 11, 1)',
'borderWidth' => 1,
'borderRadius' => 6,
'maxBarThickness' => 22,
],
[
'label' => 'Cancelados hoy',
'data' => $advisorCanceled,
'backgroundColor' => 'rgba(220, 53, 69, 0.75)',
'borderColor' => 'rgba(220, 53, 69, 1)',
'borderWidth' => 1,
'borderRadius' => 6,
'maxBarThickness' => 22,
],
],
],
'trend_chart' => [
'labels' => $trendLabels,
'datasets' => [
[
'label' => 'Asignados',
'data' => $trendAssigned,
'borderColor' => 'rgba(13, 110, 253, 1)',
'backgroundColor' => 'rgba(13, 110, 253, 0.12)',
'borderWidth' => 2,
'tension' => 0.35,
'fill' => false,
'pointRadius' => 2,
'pointHoverRadius' => 4,
],
[
'label' => 'Confirmados nuevos',
'data' => $trendConfirmedNew,
'borderColor' => 'rgba(25, 135, 84, 1)',
'backgroundColor' => 'rgba(25, 135, 84, 0.12)',
'borderWidth' => 2,
'tension' => 0.35,
'fill' => false,
'pointRadius' => 2,
'pointHoverRadius' => 4,
],
[
'label' => 'Recuperados',
'data' => $trendRecovered,
'borderColor' => 'rgba(245, 158, 11, 1)',
'backgroundColor' => 'rgba(245, 158, 11, 0.12)',
'borderWidth' => 2,
'tension' => 0.35,
'fill' => false,
'pointRadius' => 2,
'pointHoverRadius' => 4,
],
[
'label' => 'Cancelados',
'data' => $trendCanceled,
'borderColor' => 'rgba(220, 53, 69, 1)',
'backgroundColor' => 'rgba(220, 53, 69, 0.12)',
'borderWidth' => 2,
'tension' => 0.35,
'fill' => false,
'pointRadius' => 2,
'pointHoverRadius' => 4,
],
],
],
];
}
function cc_test_render_performance_dashboard(array $dashboard, array $options = []): string
{
$title = trim((string) ($options['title'] ?? 'KPI diario por asesora'));
$subtitle = trim((string) ($options['subtitle'] ?? 'Confirmación = pedidos de hoy cerrados hoy. Recuperación = pedidos de otros días cerrados hoy.'));
$chart1Title = trim((string) ($options['chart1_title'] ?? 'Rendimiento por asesora hoy'));
$chart1Note = trim((string) ($options['chart1_note'] ?? 'Asignados, confirmados nuevos, recuperados y cancelados de hoy.'));
$chart2Title = trim((string) ($options['chart2_title'] ?? 'Evolución de los últimos 7 días'));
$chart2Note = trim((string) ($options['chart2_note'] ?? 'La línea de confirmados separa pedidos nuevos y recuperados.'));
$footnote = trim((string) ($options['footnote'] ?? 'Seguimiento abierto = pedidos en SE ENVIO NUMERO DE CUENTA que todavía no pasan a Promo Final.'));
$sectionId = preg_replace('/[^A-Za-z0-9_-]+/', '', (string) ($options['section_id'] ?? 'ccPerformanceDashboard'));
if ($sectionId === '') {
$sectionId = 'ccPerformanceDashboard';
}
$summary = (array) ($dashboard['summary'] ?? []);
$advisorChart = (array) ($dashboard['advisor_chart'] ?? []);
$trendChart = (array) ($dashboard['trend_chart'] ?? []);
$advisorCount = (int) ($dashboard['advisor_count'] ?? 0);
$badges = [
['label' => 'Asignados hoy', 'value' => (int) ($summary['assigned_today'] ?? 0), 'class' => 'cc-kpi-pill-primary'],
['label' => 'Confirmados hoy', 'value' => (int) ($summary['confirmed_today_total'] ?? 0), 'class' => 'cc-kpi-pill-success'],
['label' => 'Recuperados', 'value' => (int) ($summary['confirmed_today_recovered'] ?? 0), 'class' => 'cc-kpi-pill-warning'],
['label' => 'Seguimiento abierto', 'value' => (int) ($summary['followup_open'] ?? 0), 'class' => 'cc-kpi-pill-info'],
['label' => 'Cancelados hoy', 'value' => (int) ($summary['canceled_today'] ?? 0), 'class' => 'cc-kpi-pill-danger'],
['label' => '% confirmación', 'value' => number_format((float) ($summary['confirm_rate_today'] ?? 0), 1) . '%', 'class' => 'cc-kpi-pill-dark'],
['label' => '% recuperación', 'value' => number_format((float) ($summary['recovery_rate_today'] ?? 0), 1) . '%', 'class' => 'cc-kpi-pill-secondary'],
];
$extraLineParts = [];
if (isset($summary['confirmed_today_new'])) {
$extraLineParts[] = (int) $summary['confirmed_today_new'] . ' confirmados nuevos';
}
if (isset($summary['confirmed_today_recovered'])) {
$extraLineParts[] = (int) $summary['confirmed_today_recovered'] . ' recuperados';
}
if (isset($summary['canceled_today'])) {
$extraLineParts[] = (int) $summary['canceled_today'] . ' cancelados';
}
if (isset($summary['followup_open'])) {
$extraLineParts[] = (int) $summary['followup_open'] . ' en seguimiento';
}
$extraLine = implode(' · ', $extraLineParts);
ob_start();
?>
<section class="mb-4">
<div class="card border-0 shadow-sm cc-kpi-panel">
<div class="card-body">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div class="pe-xl-3">
<div class="small text-uppercase text-primary fw-semibold mb-1">Panel KPI</div>
<h2 class="h4 fw-bold mb-1"><?php echo htmlspecialchars($title); ?></h2>
<p class="text-muted small mb-0"><?php echo htmlspecialchars($subtitle); ?></p>
</div>
<div class="d-flex flex-wrap gap-2 cc-kpi-pill-group justify-content-xl-end">
<?php foreach ($badges as $badge): ?>
<div class="cc-kpi-pill <?php echo htmlspecialchars($badge['class']); ?>">
<span class="cc-kpi-label"><?php echo htmlspecialchars((string) $badge['label']); ?></span>
<span class="cc-kpi-value"><?php echo htmlspecialchars((string) $badge['value']); ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($extraLine !== ''): ?>
<div class="cc-kpi-footnote mt-3"><?php echo htmlspecialchars($extraLine); ?></div>
<?php endif; ?>
<div class="row g-3 mt-2">
<div class="col-12 col-xl-7">
<div class="card border-0 shadow-sm h-100 cc-kpi-chart-card">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-start gap-2">
<div>
<h3 class="h6 fw-bold mb-1"><?php echo htmlspecialchars($chart1Title); ?></h3>
<div class="text-muted small"><?php echo htmlspecialchars($chart1Note); ?></div>
</div>
<span class="badge text-bg-light border align-self-start"><?php echo (int) $advisorCount; ?> <?php echo $advisorCount === 1 ? 'asesora' : 'asesoras'; ?></span>
</div>
<div class="cc-kpi-chart-wrap mt-3">
<canvas id="<?php echo htmlspecialchars($sectionId . 'AdvisorChart'); ?>"></canvas>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card border-0 shadow-sm h-100 cc-kpi-chart-card">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-start gap-2">
<div>
<h3 class="h6 fw-bold mb-1"><?php echo htmlspecialchars($chart2Title); ?></h3>
<div class="text-muted small"><?php echo htmlspecialchars($chart2Note); ?></div>
</div>
<span class="badge text-bg-light border align-self-start"><?php echo count((array) ($trendChart['labels'] ?? [])); ?> <?php echo count((array) ($trendChart['labels'] ?? [])) === 1 ? 'día' : 'días'; ?></span>
</div>
<div class="cc-kpi-chart-wrap mt-3">
<canvas id="<?php echo htmlspecialchars($sectionId . 'TrendChart'); ?>"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="cc-kpi-footnote mt-3"><?php echo htmlspecialchars($footnote); ?></div>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function () {
const dashboard = <?php echo json_encode($dashboard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
const advisorCanvas = document.getElementById(<?php echo json_encode($sectionId . 'AdvisorChart', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
const trendCanvas = document.getElementById(<?php echo json_encode($sectionId . 'TrendChart', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>);
if (!window.Chart) {
return;
}
const advisorData = dashboard.advisor_chart || { labels: [], datasets: [] };
const trendData = dashboard.trend_chart || { labels: [], datasets: [] };
if (advisorCanvas) {
new Chart(advisorCanvas, {
type: 'bar',
data: advisorData,
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
precision: 0,
stepSize: 1
}
},
y: {
ticks: {
autoSkip: false
}
}
}
}
});
}
if (trendCanvas) {
new Chart(trendCanvas, {
type: 'line',
data: trendData,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
stepSize: 1
}
}
}
}
});
}
})();
</script>
<?php
return trim((string) ob_get_clean());
}
function cc_test_state_label(string $estado): string
{
return match (cc_test_normalize_state($estado)) {

View File

@ -663,8 +663,8 @@ function drive_test_fetch_orders_from_db(PDO $pdo, string $storeKey, ?bool $only
FROM callcenter_test_orders
$where
ORDER BY
CASE WHEN source_row IS NULL THEN 1 ELSE 0 END ASC,
source_row ASC,
COALESCE(drive_imported_at, first_seen_at) DESC,
source_row DESC,
id DESC"
);
$stmt->execute($params);