Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -30,6 +30,10 @@ const Customers = (() => {
|
||||
return '$' + n.toFixed(0);
|
||||
};
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
||||
}
|
||||
@@ -281,6 +285,7 @@ const Customers = (() => {
|
||||
set('detailEmail', c.email);
|
||||
set('detailSince', formatDate(c.created_at));
|
||||
set('detailLastPurchase', formatDate(c.last_purchase));
|
||||
if (typeof loadCustomerCRM === 'function') loadCustomerCRM(c.id);
|
||||
|
||||
// Credit
|
||||
const limit = parseFloat(c.credit_limit || 0);
|
||||
@@ -733,6 +738,137 @@ const Customers = (() => {
|
||||
if (typeof openSlidePanel === 'function') openSlidePanel();
|
||||
}
|
||||
|
||||
|
||||
// ─── CRM & Loyalty ───────────────────────
|
||||
async function loadCustomerCRM(customerId) {
|
||||
try {
|
||||
const [loy, acts, analytics, creditScore] = await Promise.all([
|
||||
api(`/pos/api/customers/${customerId}/loyalty`),
|
||||
api(`/pos/api/customers/${customerId}/activities`).catch(() => ({ data: [] })),
|
||||
api(`/pos/api/customers/${customerId}/analytics`).catch(() => null),
|
||||
api(`/pos/api/customers/${customerId}/credit-score`).catch(() => null),
|
||||
]);
|
||||
const balEl = document.getElementById('crmLoyaltyBalance');
|
||||
if (balEl) balEl.textContent = (loy.balance != null ? loy.balance : 0);
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
if (actsEl) {
|
||||
const list = acts.data || acts.activities || [];
|
||||
if (!list.length) {
|
||||
actsEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin actividad registrada</span>';
|
||||
} else {
|
||||
actsEl.innerHTML = list.slice(0, 20).map(a =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${esc(a.created_at || '')} · ${esc(a.activity_type || '')}</div>
|
||||
<div style="font-size:var(--text-body-sm);">${esc(a.description || a.title || '')}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
}
|
||||
renderCustomerInsights(analytics, creditScore);
|
||||
} catch (e) {
|
||||
console.warn('CRM load failed:', e);
|
||||
}
|
||||
}
|
||||
window.loadCustomerCRM = loadCustomerCRM;
|
||||
|
||||
// Renders analytics (LTV, next purchase, churn) + credit risk score into
|
||||
// the CRM section of the customer detail panel. All values come from the
|
||||
// backend; missing data is shown as "—" rather than guessed.
|
||||
function renderCustomerInsights(analytics, creditScore) {
|
||||
const section = document.getElementById('crmSection');
|
||||
if (!section) return;
|
||||
let el = document.getElementById('crmInsights');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'crmInsights';
|
||||
el.style.cssText = 'display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);' +
|
||||
'margin-bottom:var(--space-3);';
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
section.insertBefore(el, actsEl || null);
|
||||
}
|
||||
if (!analytics && !creditScore) { el.innerHTML = ''; return; }
|
||||
|
||||
const card = (label, value, sub, color) =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-wider);">${esc(label)}</div>
|
||||
<div style="font-size:var(--text-body-sm);font-weight:700;color:${color || 'var(--color-text-primary)'};">${value}</div>
|
||||
${sub ? `<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
const cards = [];
|
||||
const a = analytics || {};
|
||||
|
||||
cards.push(card('Valor de vida (LTV)', fmt(a.ltv),
|
||||
`${(a.total_orders || 0)} compras · ticket ${fmt(a.avg_order_value)}`));
|
||||
|
||||
cards.push(card('Intervalo de compra',
|
||||
(a.avg_days_between_purchases != null && a.avg_days_between_purchases > 0)
|
||||
? `${a.avg_days_between_purchases} días` : '—',
|
||||
(a.days_since_last_purchase != null)
|
||||
? `Última hace ${a.days_since_last_purchase} días` : 'Sin compras'));
|
||||
|
||||
let nextSub = '';
|
||||
if (a.days_until_next_purchase != null) {
|
||||
nextSub = a.days_until_next_purchase >= 0
|
||||
? `En ${a.days_until_next_purchase} días`
|
||||
: `Vencida hace ${Math.abs(a.days_until_next_purchase)} días`;
|
||||
}
|
||||
cards.push(card('Próxima compra probable',
|
||||
a.predicted_next_purchase_date ? formatDate(a.predicted_next_purchase_date) : '—',
|
||||
nextSub));
|
||||
|
||||
const riskColor = a.churn_risk === 'high' ? 'var(--color-error)'
|
||||
: a.churn_risk === 'medium' ? '#d97706' : 'var(--color-success, #16a34a)';
|
||||
const riskLabel = a.churn_risk === 'high' ? 'Alto' : a.churn_risk === 'medium' ? 'Medio' : 'Bajo';
|
||||
const probTxt = (a.churn_probability != null) ? ` (${Math.round(a.churn_probability * 100)}%)` : '';
|
||||
cards.push(card('Riesgo de abandono (churn)',
|
||||
`<span style="color:${riskColor};">${riskLabel}${probTxt}</span>`,
|
||||
esc(a.churn_reason || '')));
|
||||
|
||||
if (creditScore && creditScore.score != null) {
|
||||
const tierMap = { good: 'Bueno', medium: 'Medio', risk: 'Riesgo' };
|
||||
const tierColor = creditScore.tier === 'good' ? 'var(--color-success, #16a34a)'
|
||||
: creditScore.tier === 'medium' ? '#d97706' : 'var(--color-error)';
|
||||
const reasons = (creditScore.reasons || []).slice(0, 2).map(esc).join(' · ');
|
||||
cards.push(card('Score de crédito',
|
||||
`<span style="color:${tierColor};">${creditScore.score}/100 · ${esc(tierMap[creditScore.tier] || creditScore.tier)}</span>`,
|
||||
`Límite sugerido ${fmt(creditScore.recommended_limit)} · ${esc(creditScore.data_points)} señales` +
|
||||
(reasons ? `<br>${reasons}` : '')));
|
||||
}
|
||||
|
||||
el.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
async function crmRedeem() {
|
||||
if (!currentCustomer) return;
|
||||
const pts = prompt('Puntos a canjear:');
|
||||
if (!pts) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/loyalty/redeem`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ points: parseInt(pts, 10) }),
|
||||
});
|
||||
alert('Canje registrado');
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function crmAddNote() {
|
||||
if (!currentCustomer) return;
|
||||
const note = prompt('Nota sobre el cliente:');
|
||||
if (!note) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/activities`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ activity_type: 'note', description: note }),
|
||||
});
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
function init() {
|
||||
// Auth check
|
||||
@@ -1011,6 +1147,8 @@ const Customers = (() => {
|
||||
}
|
||||
};
|
||||
|
||||
publicApi.crmRedeem = crmRedeem;
|
||||
publicApi.crmAddNote = crmAddNote;
|
||||
// Expose globally for inline HTML onclick handlers
|
||||
window.Customers = publicApi;
|
||||
return publicApi;
|
||||
|
||||
Reference in New Issue
Block a user