ajustes de funcionalidade
This commit is contained in:
parent
ab1445574f
commit
f75926bc3c
967
home/templates/home/controle_saldo.html
Normal file
967
home/templates/home/controle_saldo.html
Normal file
@ -0,0 +1,967 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Controle de Saldo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--header-h: 64px;
|
||||||
|
--accent: #03506B;
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
--accent-light: #046b8f;
|
||||||
|
--bg-color: #f6f8fb;
|
||||||
|
--text-primary: #111;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--white: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
height: var(--header-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1200;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sidebar {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--white);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sidebar svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sidebar:hover svg {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
height: 40px;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-header {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--header-h);
|
||||||
|
left: 0;
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
height: calc(100vh - var(--header-h));
|
||||||
|
background: var(--white);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
padding: 14px 10px;
|
||||||
|
box-shadow: 4px 0 30px rgba(0, 0, 0, 0.18);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.28s ease;
|
||||||
|
z-index: 5000;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: var(--header-h) 0 0 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.28s, visibility 0.28s;
|
||||||
|
z-index: 4800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrap {
|
||||||
|
padding: 18px;
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: #334155;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear:hover {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-title {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-green { color: #15803d; }
|
||||||
|
.coverage-yellow { color: #a16207; }
|
||||||
|
.coverage-red { color: #b91c1c; }
|
||||||
|
.saldo-negative { color: #b91c1c; font-weight: 700; }
|
||||||
|
|
||||||
|
.selectors,
|
||||||
|
.balances {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label,
|
||||||
|
.slider-wrap label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--white);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-title { margin: 0 0 4px 0; font-size: 12px; color: var(--text-secondary); font-weight: 700; }
|
||||||
|
.balance-value { margin: 0; font-size: 22px; font-weight: 800; }
|
||||||
|
.balance-value-small { margin: 0; font-size: 19px; font-weight: 800; }
|
||||||
|
|
||||||
|
.saldo-summary {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-summary-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-wrap {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr 110px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-center {
|
||||||
|
width: 50%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-pill {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1e3a8a;
|
||||||
|
background: #dbeafe;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transferSlider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: linear-gradient(90deg, #93c5fd, #3b82f6);
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transferSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1d4ed8;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transferSlider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1d4ed8;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-meta { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.target-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--white);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--white);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading { background: #eff6ff; color: #1e40af; }
|
||||||
|
.error { background: #fef2f2; color: #b91c1c; display: none; }
|
||||||
|
|
||||||
|
.saldo-table-wrap {
|
||||||
|
margin-top: 18px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table-title {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table th,
|
||||||
|
.saldo-table td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saldo-table tbody tr:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: var(--white);
|
||||||
|
border-top: 1px solid #eaeef5;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.selectors, .balances, .coverage-row, .saldo-summary-grid { grid-template-columns: 1fr; }
|
||||||
|
.slider-compact { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.slider-center { width: 100%; }
|
||||||
|
.balance-value { font-size: 19px; }
|
||||||
|
.header-actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="home-container">
|
||||||
|
<header class="home-header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<button id="toggleSidebar" class="btn-sidebar" title="Abrir menu">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<img src="https://api.grupoginseng.com.br/content-thumbs/logo.ginseng.branca.png" alt="Logo Ginseng" class="logo-img">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<a class="btn-header" href="/" title="Voltar para o Home">Voltar ao Home</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside id="sidebar" class="sidebar" role="navigation">
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a class="sidebar-item" href="/" title="Dashboard">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a class="sidebar-item" href="#/vendas" title="Vendas">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
|
</svg>
|
||||||
|
Vendas
|
||||||
|
</a>
|
||||||
|
<a class="sidebar-item" href="#/sugestoes" title="Sugestões">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Sugestões
|
||||||
|
</a>
|
||||||
|
<a class="sidebar-item" href="#/relatorios" title="Relatórios">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Relatórios
|
||||||
|
</a>
|
||||||
|
<a class="sidebar-item active" href="/home/controle-saldo/" title="Controle de Saldo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 7h16M4 12h10M4 17h16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 10l4 2-4 2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Controle de Saldo
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div id="backdrop" class="backdrop"></div>
|
||||||
|
|
||||||
|
<main class="main-wrap">
|
||||||
|
<section class="chart-panel" aria-label="Transferência de saldo">
|
||||||
|
<h2 class="panel-title">Transferência de saldo</h2>
|
||||||
|
<div class="transfer-info" id="transferInfo">Selecione duas combinações de PDV + Marca para iniciar a simulação.</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<button id="btnLimparDados" class="btn-clear" type="button">Limpar Dados</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selectors">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pdvOrigem">PDV + Marca Origem (esquerda)</label>
|
||||||
|
<input id="pdvOrigem" list="pdvOrigemList" autocomplete="off" placeholder="Digite ou selecione PDV + Marca" />
|
||||||
|
<datalist id="pdvOrigemList"></datalist>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pdvDestino">PDV + Marca Destino (direita)</label>
|
||||||
|
<input id="pdvDestino" list="pdvDestinoList" autocomplete="off" placeholder="Digite ou selecione PDV + Marca" />
|
||||||
|
<datalist id="pdvDestinoList"></datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="balances">
|
||||||
|
<div class="balance-card">
|
||||||
|
<p class="balance-title">Orçamento disponível Mês - Origem</p>
|
||||||
|
<p class="balance-value" id="saldoOrigem">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card">
|
||||||
|
<p class="balance-title">Orçamento disponível Mês - Destino</p>
|
||||||
|
<p class="balance-value" id="saldoDestino">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="saldo-summary">
|
||||||
|
<p class="saldo-summary-title">Composição do saldo (PDV origem)</p>
|
||||||
|
<div class="saldo-summary-grid">
|
||||||
|
<div class="balance-card">
|
||||||
|
<p class="balance-title">Total Orçamento do mês</p>
|
||||||
|
<p class="balance-value-small" id="orcamentoMesOrigem">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card">
|
||||||
|
<p class="balance-title">Pendente - Ignorado</p>
|
||||||
|
<p class="balance-value-small" id="pendenteMenosIgnoradoOrigem">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card">
|
||||||
|
<p class="balance-title">Saldo</p>
|
||||||
|
<p class="balance-value-small" id="saldoCompostoOrigem">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="coverage-row">
|
||||||
|
<div class="coverage-card">
|
||||||
|
<p class="coverage-title">Cobertura PDV origem</p>
|
||||||
|
<p class="coverage-value" id="coberturaOrigem">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-card">
|
||||||
|
<p class="coverage-title">Cobertura PDV destino</p>
|
||||||
|
<p class="coverage-value" id="coberturaDestino">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-wrap">
|
||||||
|
<label for="transferSlider">Valor para transferir da esquerda para a direita</label>
|
||||||
|
<div class="slider-center">
|
||||||
|
<div class="slider-compact">
|
||||||
|
<div class="slider-pill">Origem</div>
|
||||||
|
<input type="range" id="transferSlider" min="0" max="0" step="0.01" value="0" />
|
||||||
|
<div class="slider-pill">Destino</div>
|
||||||
|
</div>
|
||||||
|
<div class="target-wrap">
|
||||||
|
<label for="saldoDestinoDesejado">Saldo desejado no PDV destino</label>
|
||||||
|
<input id="saldoDestinoDesejado" type="number" min="0" step="0.01" placeholder="Digite o saldo final desejado no destino" />
|
||||||
|
</div>
|
||||||
|
<div class="transfer-wrap">
|
||||||
|
<label for="quantidadeTransferir">Quantidade a transferir</label>
|
||||||
|
<input id="quantidadeTransferir" type="number" min="0" step="0.01" placeholder="Digite o valor a retirar do PDV origem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="slider-meta" id="sliderMeta">Transferência: R$ 0,00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loadingState">Carregando saldos por PDV...</div>
|
||||||
|
<div class="error" id="errorState"></div>
|
||||||
|
|
||||||
|
<div class="saldo-table-wrap">
|
||||||
|
<h3 class="saldo-table-title">Saldos e Cobertura (menor saldo para maior)</h3>
|
||||||
|
<div class="saldo-table-container">
|
||||||
|
<table class="saldo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PDV</th>
|
||||||
|
<th>Marca</th>
|
||||||
|
<th>Saldo</th>
|
||||||
|
<th>Cobertura</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="saldoTableBody">
|
||||||
|
<tr><td colspan="4">Carregando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 Ginseng Pedidos. Todos os direitos reservados.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let pdvData = [];
|
||||||
|
|
||||||
|
function formatCurrency(value) {
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function getSelectedValues() {
|
||||||
|
return { origem: byId('pdvOrigem').value, destino: byId('pdvDestino').value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPdvItem(key) {
|
||||||
|
return pdvData.find((item) => String(item.key) === String(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverageClass(cobertura) {
|
||||||
|
const val = Number(cobertura) || 0;
|
||||||
|
if ((val >= 55 && val <= 60)) return 'coverage-green';
|
||||||
|
if ((val >= 45 && val < 55) || (val > 60 && val <= 65)) return 'coverage-yellow';
|
||||||
|
return 'coverage-red';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSaldoTable() {
|
||||||
|
const tbody = byId('saldoTableBody');
|
||||||
|
const data = pdvData
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (Number(a.orcamento_disponivel) || 0) - (Number(b.orcamento_disponivel) || 0));
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4">Nenhum registro para exibir.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = data.map((item) => {
|
||||||
|
const saldoNum = Number(item.orcamento_disponivel) || 0;
|
||||||
|
const saldo = formatCurrency(saldoNum);
|
||||||
|
const saldoClass = saldoNum < 0 ? 'saldo-negative' : '';
|
||||||
|
const cobertura = Math.round(Number(item.cobertura_dias) || 0);
|
||||||
|
const coberturaClass = getCoverageClass(cobertura);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${item.pdv || '-'}</td>
|
||||||
|
<td>${item.marca || '-'}</td>
|
||||||
|
<td class="${saldoClass}">${saldo}</td>
|
||||||
|
<td class="${coberturaClass}">${cobertura}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCoverage(origemItem, destinoItem) {
|
||||||
|
const origemEl = byId('coberturaOrigem');
|
||||||
|
const destinoEl = byId('coberturaDestino');
|
||||||
|
origemEl.className = 'coverage-value';
|
||||||
|
destinoEl.className = 'coverage-value';
|
||||||
|
|
||||||
|
if (!origemItem || !destinoItem) {
|
||||||
|
origemEl.textContent = '-';
|
||||||
|
destinoEl.textContent = '-';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cobOrigem = Number(origemItem.cobertura_dias) || 0;
|
||||||
|
const cobDestino = Number(destinoItem.cobertura_dias) || 0;
|
||||||
|
origemEl.textContent = `${origemItem.marca}: ${Math.round(cobOrigem)}`;
|
||||||
|
destinoEl.textContent = `${destinoItem.marca}: ${Math.round(cobDestino)}`;
|
||||||
|
origemEl.classList.add(getCoverageClass(cobOrigem));
|
||||||
|
destinoEl.classList.add(getCoverageClass(cobDestino));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOrigemSummary() {
|
||||||
|
const origemSelecionada = byId('pdvOrigem').value;
|
||||||
|
const origemItem = getPdvItem(origemSelecionada);
|
||||||
|
const dataset = origemItem ? [origemItem] : pdvData;
|
||||||
|
|
||||||
|
const totals = dataset.reduce((acc, item) => {
|
||||||
|
const orcamento = Number(item.orcamento_bruto) || 0;
|
||||||
|
const pendente = Number(item.pendente_api) || 0;
|
||||||
|
const ignorado = Number(item.pendente_ignorado_api) || 0;
|
||||||
|
acc.orcamento += orcamento;
|
||||||
|
acc.pendenteMenosIgnorado += (pendente - ignorado);
|
||||||
|
acc.saldo += (orcamento - pendente + ignorado);
|
||||||
|
return acc;
|
||||||
|
}, { orcamento: 0, pendenteMenosIgnorado: 0, saldo: 0 });
|
||||||
|
|
||||||
|
byId('orcamentoMesOrigem').textContent = formatCurrency(totals.orcamento);
|
||||||
|
byId('pendenteMenosIgnoradoOrigem').textContent = formatCurrency(totals.pendenteMenosIgnorado);
|
||||||
|
byId('saldoCompostoOrigem').textContent = formatCurrency(totals.saldo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectOptions() {
|
||||||
|
const origem = byId('pdvOrigem');
|
||||||
|
const destino = byId('pdvDestino');
|
||||||
|
const origemList = byId('pdvOrigemList');
|
||||||
|
const destinoList = byId('pdvDestinoList');
|
||||||
|
const origemOptions = pdvData
|
||||||
|
.map((item) => {
|
||||||
|
const saldo = Number(item.orcamento_disponivel) || 0;
|
||||||
|
const bloqueado = saldo <= 0 ? ' (sem saldo para origem)' : '';
|
||||||
|
const label = `${item.pdv} | ${item.marca}${item.descricao_pdv ? ` - ${item.descricao_pdv}` : ''}${bloqueado}`;
|
||||||
|
return `<option value="${item.key}" label="${label}"></option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const destinoOptions = pdvData
|
||||||
|
.map((item) => {
|
||||||
|
const label = `${item.pdv} | ${item.marca}${item.descricao_pdv ? ` - ${item.descricao_pdv}` : ''}`;
|
||||||
|
return `<option value="${item.key}" label="${label}"></option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
origemList.innerHTML = origemOptions;
|
||||||
|
destinoList.innerHTML = destinoOptions;
|
||||||
|
origem.value = '';
|
||||||
|
destino.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSimulation(source = 'slider') {
|
||||||
|
const { origem, destino } = getSelectedValues();
|
||||||
|
const origemItem = getPdvItem(origem);
|
||||||
|
const destinoItem = getPdvItem(destino);
|
||||||
|
const saldoOrigemEl = byId('saldoOrigem');
|
||||||
|
const saldoDestinoEl = byId('saldoDestino');
|
||||||
|
const infoEl = byId('transferInfo');
|
||||||
|
const slider = byId('transferSlider');
|
||||||
|
const sliderMeta = byId('sliderMeta');
|
||||||
|
const inputSaldoDestinoDesejado = byId('saldoDestinoDesejado');
|
||||||
|
const inputQuantidadeTransferir = byId('quantidadeTransferir');
|
||||||
|
|
||||||
|
if (origemItem && (Number(origemItem.orcamento_disponivel) || 0) <= 0) {
|
||||||
|
byId('pdvOrigem').value = '';
|
||||||
|
saldoOrigemEl.textContent = formatCurrency(0);
|
||||||
|
saldoDestinoEl.textContent = formatCurrency(0);
|
||||||
|
slider.min = '0';
|
||||||
|
slider.max = '0';
|
||||||
|
slider.value = '0';
|
||||||
|
sliderMeta.textContent = 'Transferência: R$ 0,00';
|
||||||
|
infoEl.textContent = 'PDV origem com saldo zerado/negativo não pode ser selecionado para transferência.';
|
||||||
|
renderCoverage(null, null);
|
||||||
|
updateOrigemSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!origemItem || !destinoItem || origem === destino) {
|
||||||
|
saldoOrigemEl.textContent = formatCurrency(0);
|
||||||
|
saldoDestinoEl.textContent = formatCurrency(0);
|
||||||
|
slider.min = '0';
|
||||||
|
slider.max = '0';
|
||||||
|
slider.value = '0';
|
||||||
|
if (source !== 'target') inputSaldoDestinoDesejado.value = '';
|
||||||
|
if (source !== 'transfer') inputQuantidadeTransferir.value = '';
|
||||||
|
sliderMeta.textContent = 'Transferência: R$ 0,00';
|
||||||
|
infoEl.textContent = origem && destino && origem === destino
|
||||||
|
? 'Escolha PDVs diferentes para simular a transferência.'
|
||||||
|
: 'Selecione duas combinações de PDV + Marca para iniciar a simulação.';
|
||||||
|
renderCoverage(null, null);
|
||||||
|
updateOrigemSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saldoOrigem = Number(origemItem.orcamento_disponivel) || 0;
|
||||||
|
const saldoDestino = Number(destinoItem.orcamento_disponivel) || 0;
|
||||||
|
const maxTransfer = Math.max(0, saldoOrigem);
|
||||||
|
|
||||||
|
slider.min = '0';
|
||||||
|
slider.max = String(maxTransfer.toFixed(2));
|
||||||
|
if ((Number(slider.value) || 0) > maxTransfer) {
|
||||||
|
slider.value = String(maxTransfer.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'target') {
|
||||||
|
const rawTarget = String(inputSaldoDestinoDesejado.value || '').replace(',', '.').trim();
|
||||||
|
if (rawTarget === '') {
|
||||||
|
slider.value = '0';
|
||||||
|
} else {
|
||||||
|
const targetDestino = Number(rawTarget);
|
||||||
|
if (Number.isFinite(targetDestino)) {
|
||||||
|
const transferByTarget = clamp(targetDestino - saldoDestino, 0, maxTransfer);
|
||||||
|
slider.value = String(transferByTarget.toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (source === 'transfer') {
|
||||||
|
const rawTransfer = String(inputQuantidadeTransferir.value || '').replace(',', '.').trim();
|
||||||
|
if (rawTransfer === '') {
|
||||||
|
slider.value = '0';
|
||||||
|
} else {
|
||||||
|
const transferManual = Number(rawTransfer);
|
||||||
|
if (Number.isFinite(transferManual)) {
|
||||||
|
slider.value = String(clamp(transferManual, 0, maxTransfer).toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfer = Number(slider.value) || 0;
|
||||||
|
const origemSimulado = saldoOrigem - transfer;
|
||||||
|
const destinoSimulado = saldoDestino + transfer;
|
||||||
|
if (source !== 'target') {
|
||||||
|
inputSaldoDestinoDesejado.value = destinoSimulado.toFixed(2);
|
||||||
|
}
|
||||||
|
if (source !== 'transfer') {
|
||||||
|
inputQuantidadeTransferir.value = transfer.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
saldoOrigemEl.textContent = formatCurrency(origemSimulado);
|
||||||
|
saldoDestinoEl.textContent = formatCurrency(destinoSimulado);
|
||||||
|
sliderMeta.textContent = `Transferência: ${formatCurrency(transfer)}`;
|
||||||
|
infoEl.textContent = `Tirando ${formatCurrency(transfer)} de ${origemItem.pdv} (${origemItem.marca}) e passando para ${destinoItem.pdv} (${destinoItem.marca}).`;
|
||||||
|
renderCoverage(origemItem, destinoItem);
|
||||||
|
updateOrigemSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const loading = byId('loadingState');
|
||||||
|
const error = byId('errorState');
|
||||||
|
try {
|
||||||
|
loading.style.display = 'block';
|
||||||
|
error.style.display = 'none';
|
||||||
|
|
||||||
|
const response = await fetch('/home/api/controle-saldo-data/');
|
||||||
|
if (!response.ok) throw new Error('Falha ao carregar dados do controle de saldo.');
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status !== 'success') throw new Error(result.message || 'Erro ao carregar dados.');
|
||||||
|
|
||||||
|
pdvData = Array.isArray(result.data) ? result.data : [];
|
||||||
|
renderSelectOptions();
|
||||||
|
updateSimulation('slider');
|
||||||
|
updateOrigemSummary();
|
||||||
|
|
||||||
|
byId('pdvOrigem').addEventListener('input', () => updateSimulation('slider'));
|
||||||
|
byId('pdvDestino').addEventListener('input', () => updateSimulation('slider'));
|
||||||
|
byId('transferSlider').addEventListener('input', () => updateSimulation('slider'));
|
||||||
|
byId('saldoDestinoDesejado').addEventListener('input', (event) => {
|
||||||
|
const onlyDotNumber = String(event.target.value || '').replace(',', '.').replace(/[^\d.]/g, '');
|
||||||
|
event.target.value = onlyDotNumber;
|
||||||
|
updateSimulation('target');
|
||||||
|
});
|
||||||
|
byId('quantidadeTransferir').addEventListener('input', (event) => {
|
||||||
|
const onlyDotNumber = String(event.target.value || '').replace(',', '.').replace(/[^\d.]/g, '');
|
||||||
|
event.target.value = onlyDotNumber;
|
||||||
|
updateSimulation('transfer');
|
||||||
|
});
|
||||||
|
byId('btnLimparDados').addEventListener('click', () => {
|
||||||
|
byId('pdvOrigem').value = '';
|
||||||
|
byId('pdvDestino').value = '';
|
||||||
|
byId('saldoDestinoDesejado').value = '';
|
||||||
|
byId('quantidadeTransferir').value = '';
|
||||||
|
byId('transferSlider').value = '0';
|
||||||
|
renderSelectOptions();
|
||||||
|
renderSaldoTable();
|
||||||
|
updateSimulation('slider');
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSaldoTable();
|
||||||
|
loading.style.display = 'none';
|
||||||
|
} catch (e) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
error.textContent = e.message || 'Erro inesperado ao carregar dados.';
|
||||||
|
error.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeEvents() {
|
||||||
|
const toggleSidebar = byId('toggleSidebar');
|
||||||
|
const sidebar = byId('sidebar');
|
||||||
|
const backdrop = byId('backdrop');
|
||||||
|
|
||||||
|
toggleSidebar.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
backdrop.classList.toggle('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', () => {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidebar-item').forEach((item) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeEvents();
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -203,7 +203,7 @@
|
|||||||
/* ===== CARDS ===== */
|
/* ===== CARDS ===== */
|
||||||
.cards-row {
|
.cards-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.cards-row-primary {
|
.cards-row-primary {
|
||||||
@ -214,7 +214,7 @@
|
|||||||
background: var(--white);
|
background: var(--white);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
@ -232,7 +232,7 @@
|
|||||||
|
|
||||||
.card-value {
|
.card-value {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 21px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@ -587,6 +587,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Relatórios
|
Relatórios
|
||||||
</a>
|
</a>
|
||||||
|
<a class="sidebar-item" href="/home/controle-saldo/" title="Controle de Saldo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 7h16M4 12h10M4 17h16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 10l4 2-4 2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Controle de Saldo
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -720,6 +727,10 @@
|
|||||||
<p class="card-title">Cobertura projetada com compra</p>
|
<p class="card-title">Cobertura projetada com compra</p>
|
||||||
<p class="card-value" id="coberturaProjetadaCompra">-</p>
|
<p class="card-value" id="coberturaProjetadaCompra">-</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-title">Aprovado Hoje</p>
|
||||||
|
<p class="card-value" id="aprovadoHojeCard">-</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- PIVOT TABLE -->
|
<!-- PIVOT TABLE -->
|
||||||
@ -744,6 +755,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let rawPivotData = [];
|
let rawPivotData = [];
|
||||||
|
let aprovadoHojeTotal = 0;
|
||||||
let filterRenderTimer = null;
|
let filterRenderTimer = null;
|
||||||
let pivotPluginUnavailableLogged = false;
|
let pivotPluginUnavailableLogged = false;
|
||||||
let apiFilterLoadTimer = null;
|
let apiFilterLoadTimer = null;
|
||||||
@ -889,6 +901,7 @@
|
|||||||
|
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
rawPivotData = Array.isArray(data) ? data : [];
|
rawPivotData = Array.isArray(data) ? data : [];
|
||||||
|
aprovadoHojeTotal = Number(result.aprovado_hoje_total) || 0;
|
||||||
populateCategoriaDropdown(result.categorias || []);
|
populateCategoriaDropdown(result.categorias || []);
|
||||||
populateClasseDropdown(result.classes || []);
|
populateClasseDropdown(result.classes || []);
|
||||||
populateCicloDropdown(result.ciclos || [], cicloSelecionado);
|
populateCicloDropdown(result.ciclos || [], cicloSelecionado);
|
||||||
@ -1080,7 +1093,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupedSummary = aggregateSummaryRows(filtered);
|
const groupedSummary = aggregateSummaryRows(filtered);
|
||||||
updateDashboardCards(groupedSummary);
|
updateDashboardCards(groupedSummary, aprovadoHojeTotal);
|
||||||
|
|
||||||
filterRenderTimer = setTimeout(() => {
|
filterRenderTimer = setTimeout(() => {
|
||||||
requestAnimationFrame(() => initializePivotTable(groupedSummary));
|
requestAnimationFrame(() => initializePivotTable(groupedSummary));
|
||||||
@ -1165,7 +1178,7 @@
|
|||||||
return Array.from(orcamentoPorPdv.values()).reduce((acc, val) => acc + val, 0);
|
return Array.from(orcamentoPorPdv.values()).reduce((acc, val) => acc + val, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardCards(data) {
|
function updateDashboardCards(data, aprovadoHoje = 0) {
|
||||||
const rows = Array.isArray(data) ? data : [];
|
const rows = Array.isArray(data) ? data : [];
|
||||||
const estoqueGeral = rows.reduce((acc, row) => {
|
const estoqueGeral = rows.reduce((acc, row) => {
|
||||||
const val = Number(row.Estoque_Total);
|
const val = Number(row.Estoque_Total);
|
||||||
@ -1207,6 +1220,7 @@
|
|||||||
const coberturaEstTransCard = document.getElementById('coberturaEstTrans');
|
const coberturaEstTransCard = document.getElementById('coberturaEstTrans');
|
||||||
const coberturaEstTransPendCard = document.getElementById('coberturaEstTransPend');
|
const coberturaEstTransPendCard = document.getElementById('coberturaEstTransPend');
|
||||||
const coberturaProjetadaCompraCard = document.getElementById('coberturaProjetadaCompra');
|
const coberturaProjetadaCompraCard = document.getElementById('coberturaProjetadaCompra');
|
||||||
|
const aprovadoHojeCard = document.getElementById('aprovadoHojeCard');
|
||||||
|
|
||||||
if (estoqueCard) estoqueCard.textContent = formatNumberPtBr(estoqueGeral);
|
if (estoqueCard) estoqueCard.textContent = formatNumberPtBr(estoqueGeral);
|
||||||
if (transitoCard) transitoCard.textContent = formatNumberPtBr(transitoGeral);
|
if (transitoCard) transitoCard.textContent = formatNumberPtBr(transitoGeral);
|
||||||
@ -1218,6 +1232,7 @@
|
|||||||
if (coberturaEstTransCard) coberturaEstTransCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransGeral));
|
if (coberturaEstTransCard) coberturaEstTransCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransGeral));
|
||||||
if (coberturaEstTransPendCard) coberturaEstTransPendCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransPendGeral));
|
if (coberturaEstTransPendCard) coberturaEstTransPendCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransPendGeral));
|
||||||
if (coberturaProjetadaCompraCard) coberturaProjetadaCompraCard.textContent = formatNumberPtBr(Math.trunc(coberturaProjetadaCompraGeral));
|
if (coberturaProjetadaCompraCard) coberturaProjetadaCompraCard.textContent = formatNumberPtBr(Math.trunc(coberturaProjetadaCompraGeral));
|
||||||
|
if (aprovadoHojeCard) aprovadoHojeCard.textContent = formatCurrencyPtBr(aprovadoHoje);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateCicloDropdown(ciclos, selectedCiclo = '') {
|
function populateCicloDropdown(ciclos, selectedCiclo = '') {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ urlpatterns = [
|
|||||||
# Esta app é montada em /home/, mas também há um path('/') no projeto
|
# Esta app é montada em /home/, mas também há um path('/') no projeto
|
||||||
# principal para renderizar a mesma página inicial.
|
# principal para renderizar a mesma página inicial.
|
||||||
path("", views.home_page, name="home_page"),
|
path("", views.home_page, name="home_page"),
|
||||||
|
path("controle-saldo/", views.controle_saldo_page, name="controle_saldo_page"),
|
||||||
path("api/table-data/", views.get_table_data, name="table_data"),
|
path("api/table-data/", views.get_table_data, name="table_data"),
|
||||||
path("api/pivot-data/", views.get_pivot_data, name="pivot_data"),
|
path("api/pivot-data/", views.get_pivot_data, name="pivot_data"),
|
||||||
|
path("api/controle-saldo-data/", views.get_controle_saldo_data, name="controle_saldo_data"),
|
||||||
]
|
]
|
||||||
|
|||||||
553
home/views.py
553
home/views.py
@ -2,6 +2,7 @@ from django.shortcuts import render
|
|||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
import logging
|
import logging
|
||||||
import pyodbc
|
import pyodbc
|
||||||
import requests
|
import requests
|
||||||
@ -34,6 +35,396 @@ def home_page(request):
|
|||||||
return render(request, "home/home_page.html", context)
|
return render(request, "home/home_page.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def controle_saldo_page(request):
|
||||||
|
"""Página simulador de transferência de saldo entre PDVs."""
|
||||||
|
context = {
|
||||||
|
'user_email': request.user.email if request.user.is_authenticated else '',
|
||||||
|
'is_admin': request.user.is_staff if request.user.is_authenticated else False,
|
||||||
|
}
|
||||||
|
return render(request, "home/controle_saldo.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def get_controle_saldo_data(request):
|
||||||
|
"""Retorna saldo disponível por PDV para simulação de transferência."""
|
||||||
|
try:
|
||||||
|
saldo_orcamento_por_pdv_marca = get_api_orcamento_saldo_por_pdv_marca()
|
||||||
|
pending_por_pdv_marca = get_api_pendingorder_por_pdv_marca()
|
||||||
|
pending_ignorado_por_pdv_marca = get_api_pendingorder_ignorados_por_pdv_marca()
|
||||||
|
cobertura_por_pdv_marca = get_home_cobertura_por_pdv_marca()
|
||||||
|
base_pdvs_data = get_api_base_pdvs_data() or []
|
||||||
|
|
||||||
|
descricao_por_pdv: Dict[str, str] = {}
|
||||||
|
for row in base_pdvs_data:
|
||||||
|
pdv = _normalize_pdv(
|
||||||
|
row.get('pdv', row.get('PDV', row.get('loja_id', row.get('LOJA_ID', ''))))
|
||||||
|
)
|
||||||
|
if not pdv:
|
||||||
|
continue
|
||||||
|
descricao = str(
|
||||||
|
row.get(
|
||||||
|
'descricao_pdv',
|
||||||
|
row.get(
|
||||||
|
'DESCRICAO_PDV',
|
||||||
|
row.get('descricao', row.get('DESCRICAO', row.get('nome', row.get('NOME', ''))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or ''
|
||||||
|
).strip()
|
||||||
|
if descricao:
|
||||||
|
descricao_por_pdv[pdv] = descricao
|
||||||
|
|
||||||
|
chaves_unicas = (
|
||||||
|
set(saldo_orcamento_por_pdv_marca.keys())
|
||||||
|
| set(pending_por_pdv_marca.keys())
|
||||||
|
| set(pending_ignorado_por_pdv_marca.keys())
|
||||||
|
)
|
||||||
|
pdvs = []
|
||||||
|
for key in chaves_unicas:
|
||||||
|
pdv, marca = key.split('|', 1)
|
||||||
|
saldo = float(saldo_orcamento_por_pdv_marca.get(key, 0.0))
|
||||||
|
pending = float(pending_por_pdv_marca.get(key, 0.0))
|
||||||
|
pending_ignorado = float(pending_ignorado_por_pdv_marca.get(key, 0.0))
|
||||||
|
orcamento_disponivel = saldo - pending + pending_ignorado
|
||||||
|
pdvs.append({
|
||||||
|
'key': key,
|
||||||
|
'pdv': pdv,
|
||||||
|
'marca': marca,
|
||||||
|
'descricao_pdv': descricao_por_pdv.get(pdv, ''),
|
||||||
|
'orcamento_disponivel': orcamento_disponivel,
|
||||||
|
'cobertura_dias': float(cobertura_por_pdv_marca.get(key, 0.0)),
|
||||||
|
'orcamento_bruto': saldo,
|
||||||
|
'pendente_api': pending,
|
||||||
|
'pendente_ignorado_api': pending_ignorado,
|
||||||
|
})
|
||||||
|
|
||||||
|
pdvs.sort(key=lambda item: (
|
||||||
|
(item.get('descricao_pdv') or '').lower(),
|
||||||
|
(item.get('pdv') or ''),
|
||||||
|
(item.get('marca') or ''),
|
||||||
|
))
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'data': pdvs,
|
||||||
|
'count': len(pdvs),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Erro ao carregar dados de controle de saldo")
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'data': []
|
||||||
|
}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
def get_home_cobertura_por_pdv() -> Dict[str, float]:
|
||||||
|
"""Calcula cobertura (dias) agregada por PDV usando a mesma base SQL do /home."""
|
||||||
|
try:
|
||||||
|
cache_key = "home_cobertura_pdv"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
query = """
|
||||||
|
WITH draft as (
|
||||||
|
SELECT
|
||||||
|
dh.businessunit AS MARCA,
|
||||||
|
dh.loja_id AS PDV,
|
||||||
|
dh.code AS SKU,
|
||||||
|
dh.stock_actual AS ESTOQUE,
|
||||||
|
dh.pendingorder AS PENDENTE,
|
||||||
|
dh.stock_intransit AS TRANSITO
|
||||||
|
FROM draft_historico dh with(nolock)
|
||||||
|
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
emh.PDV,
|
||||||
|
emh.SKU,
|
||||||
|
emh.[DDV PREVISTO] AS DDV_PREVISTO,
|
||||||
|
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
|
||||||
|
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
|
||||||
|
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
|
||||||
|
FROM estoque_mar_historico emh with(nolock)
|
||||||
|
LEFT JOIN draft d on d.PDV = emh.PDV and d.SKU = emh.SKU
|
||||||
|
WHERE emh.data_estoque = (select max(data_estoque) from estoque_mar_historico with(nolock))
|
||||||
|
and emh.CATEGORIA not in ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||||
|
and emh.pdv not in ('23703')
|
||||||
|
"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(query)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
agg: Dict[str, Dict[str, float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
||||||
|
if not pdv:
|
||||||
|
continue
|
||||||
|
if pdv not in agg:
|
||||||
|
agg[pdv] = {'estoque': 0.0, 'ddv': 0.0}
|
||||||
|
|
||||||
|
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
|
||||||
|
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
|
||||||
|
agg[pdv]['estoque'] += estoque
|
||||||
|
agg[pdv]['ddv'] += ddv
|
||||||
|
|
||||||
|
cobertura_por_pdv: Dict[str, float] = {}
|
||||||
|
for pdv, vals in agg.items():
|
||||||
|
ddv = vals['ddv']
|
||||||
|
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
|
||||||
|
cobertura_por_pdv[pdv] = round(cobertura, 0)
|
||||||
|
|
||||||
|
cache.set(cache_key, cobertura_por_pdv, timeout=300)
|
||||||
|
return cobertura_por_pdv
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar cobertura SQL do /home (PDV): %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_home_cobertura_por_pdv_marca() -> Dict[str, float]:
|
||||||
|
"""Calcula cobertura (dias) por PDV+MARCA usando a mesma base SQL do /home."""
|
||||||
|
try:
|
||||||
|
cache_key = "home_cobertura_pdv_marca"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
query = """
|
||||||
|
WITH draft as (
|
||||||
|
SELECT
|
||||||
|
dh.businessunit AS MARCA,
|
||||||
|
dh.loja_id AS PDV,
|
||||||
|
dh.code AS SKU,
|
||||||
|
dh.stock_actual AS ESTOQUE,
|
||||||
|
dh.pendingorder AS PENDENTE,
|
||||||
|
dh.stock_intransit AS TRANSITO
|
||||||
|
FROM draft_historico dh with(nolock)
|
||||||
|
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN emh.DESCRICAO LIKE '%OUI%' THEN 'OUI'
|
||||||
|
ELSE emh.ORIGEM
|
||||||
|
END AS MARCA,
|
||||||
|
emh.PDV,
|
||||||
|
emh.SKU,
|
||||||
|
emh.[DDV PREVISTO] AS DDV_PREVISTO,
|
||||||
|
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
|
||||||
|
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
|
||||||
|
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
|
||||||
|
FROM estoque_mar_historico emh with(nolock)
|
||||||
|
LEFT JOIN draft d on d.PDV = emh.PDV and d.SKU = emh.SKU
|
||||||
|
WHERE emh.data_estoque = (select max(data_estoque) from estoque_mar_historico with(nolock))
|
||||||
|
and emh.CATEGORIA not in ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
|
||||||
|
and emh.pdv not in ('23703')
|
||||||
|
"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(query)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
agg: Dict[str, Dict[str, float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
||||||
|
marca = _normalize_marca(row.get('MARCA', row.get('marca', '')))
|
||||||
|
if not pdv or not marca:
|
||||||
|
continue
|
||||||
|
key = f"{pdv}|{marca}"
|
||||||
|
if key not in agg:
|
||||||
|
agg[key] = {'estoque': 0.0, 'transito': 0.0, 'pendente': 0.0, 'ddv': 0.0}
|
||||||
|
|
||||||
|
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
|
||||||
|
transito = _parse_float(row.get('TRANSITO', row.get('transito', 0)))
|
||||||
|
pendente = _parse_float(row.get('PENDENTE', row.get('pendente', 0)))
|
||||||
|
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
|
||||||
|
|
||||||
|
agg[key]['estoque'] += estoque
|
||||||
|
agg[key]['transito'] += transito
|
||||||
|
agg[key]['pendente'] += pendente
|
||||||
|
agg[key]['ddv'] += ddv
|
||||||
|
|
||||||
|
cobertura_por_pdv_marca: Dict[str, float] = {}
|
||||||
|
for key, vals in agg.items():
|
||||||
|
ddv = vals['ddv']
|
||||||
|
# No controle de saldo exibimos cobertura atual (estoque / ddv),
|
||||||
|
# alinhada com a validação manual solicitada.
|
||||||
|
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
|
||||||
|
cobertura_por_pdv_marca[key] = round(cobertura, 0)
|
||||||
|
|
||||||
|
cache.set(cache_key, cobertura_por_pdv_marca, timeout=300)
|
||||||
|
return cobertura_por_pdv_marca
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar cobertura SQL do /home (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_orcamento_saldo_por_pdv_marca(mes: Optional[int] = None) -> Dict[str, float]:
|
||||||
|
"""Busca saldo_pdv por PDV+MARCA na API de orçamento, filtrando mês atual (ou informado)."""
|
||||||
|
try:
|
||||||
|
mes_alvo = int(mes or datetime.now().month)
|
||||||
|
cache_key = f"api_orcamento_saldo_pdv_marca:mes={mes_alvo}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||||
|
token = api_config.get('TOKEN')
|
||||||
|
url = 'https://api.grupoginseng.com.br/api/vw_orcamento_vs_notas_2026?limit=50000'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error("Erro ao chamar API orçamento (PDV+MARCA): %s", response.status_code)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
saldo_por_pdv_marca: Dict[str, float] = {}
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
mes_row = int(_parse_float(row.get('mes')))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if mes_row != mes_alvo:
|
||||||
|
continue
|
||||||
|
pdv = _normalize_pdv(row.get('pdv', row.get('PDV', row.get('loja_id', row.get('LOJA_ID', '')))))
|
||||||
|
marca = _normalize_marca(row.get('brand', row.get('BRAND', row.get('marca', row.get('MARCA', '')))))
|
||||||
|
if not pdv or not marca:
|
||||||
|
continue
|
||||||
|
saldo = _parse_float(row.get('saldo_pdv', row.get('SALDO_PDV', 0)))
|
||||||
|
key = f"{pdv}|{marca}"
|
||||||
|
saldo_por_pdv_marca[key] = saldo_por_pdv_marca.get(key, 0.0) + saldo
|
||||||
|
|
||||||
|
cache.set(cache_key, saldo_por_pdv_marca, timeout=300)
|
||||||
|
return saldo_por_pdv_marca
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Erro de conexão com API orçamento (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar API orçamento (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_pendingorder_por_pdv_marca() -> Dict[str, float]:
|
||||||
|
"""Busca pendingorder por PDV+MARCA na API e agrega por loja/marca."""
|
||||||
|
try:
|
||||||
|
cache_key = "api_pendingorder_pdv_marca"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||||
|
token = api_config.get('TOKEN')
|
||||||
|
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_draft?limit=50000'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error("Erro ao chamar API pendingorder (PDV+MARCA): %s", response.status_code)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
pending_por_pdv_marca: Dict[str, float] = {}
|
||||||
|
for row in rows:
|
||||||
|
pdv = _normalize_pdv(row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', '')))))
|
||||||
|
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
|
||||||
|
if not pdv or not marca:
|
||||||
|
continue
|
||||||
|
pending = _parse_float(
|
||||||
|
row.get(
|
||||||
|
'total_pricesellin',
|
||||||
|
row.get('TOTAL_PRICESELLIN', row.get('pendingorder', row.get('PENDINGORDER', row.get('pendente', 0))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key = f"{pdv}|{marca}"
|
||||||
|
pending_por_pdv_marca[key] = pending_por_pdv_marca.get(key, 0.0) + pending
|
||||||
|
|
||||||
|
cache.set(cache_key, pending_por_pdv_marca, timeout=300)
|
||||||
|
return pending_por_pdv_marca
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Erro de conexão com API pendingorder (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar API pendingorder (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_pendingorder_ignorados_por_pdv_marca() -> Dict[str, float]:
|
||||||
|
"""Busca pendingorder ignorado por PDV+MARCA na API e agrega por loja/marca."""
|
||||||
|
try:
|
||||||
|
cache_key = "api_pendingorder_ignorados_pdv_marca"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||||
|
token = api_config.get('TOKEN')
|
||||||
|
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_ignorados?limit=50000'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error("Erro ao chamar API pendingorder ignorados (PDV+MARCA): %s", response.status_code)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
pending_ignorado_por_pdv_marca: Dict[str, float] = {}
|
||||||
|
for row in rows:
|
||||||
|
pdv = _normalize_pdv(
|
||||||
|
row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', ''))))
|
||||||
|
)
|
||||||
|
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
|
||||||
|
if not pdv or not marca:
|
||||||
|
continue
|
||||||
|
pending_ignorado = _parse_float(
|
||||||
|
row.get(
|
||||||
|
'total_ignorado',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_IGNORADO',
|
||||||
|
row.get(
|
||||||
|
'total_pricesellin',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_PRICESELLIN',
|
||||||
|
row.get(
|
||||||
|
'pendingorder',
|
||||||
|
row.get('PENDINGORDER', row.get('pendente', 0))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key = f"{pdv}|{marca}"
|
||||||
|
pending_ignorado_por_pdv_marca[key] = pending_ignorado_por_pdv_marca.get(key, 0.0) + pending_ignorado
|
||||||
|
|
||||||
|
cache.set(cache_key, pending_ignorado_por_pdv_marca, timeout=300)
|
||||||
|
return pending_ignorado_por_pdv_marca
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Erro de conexão com API pendingorder ignorados (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar API pendingorder ignorados (PDV+MARCA): %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_table_data(request):
|
def get_table_data(request):
|
||||||
"""API endpoint que retorna os dados da query em formato JSON."""
|
"""API endpoint que retorna os dados da query em formato JSON."""
|
||||||
try:
|
try:
|
||||||
@ -41,7 +432,7 @@ def get_table_data(request):
|
|||||||
WITH draft as (
|
WITH draft as (
|
||||||
SELECT
|
SELECT
|
||||||
dh.data,
|
dh.data,
|
||||||
dh.businessunit AS MARCA,
|
CASE WHEN dh.description like '%OUI%' then 'OUI' else businessunit end as MARCA,
|
||||||
dh.loja_id AS PDV,
|
dh.loja_id AS PDV,
|
||||||
dh.code AS SKU,
|
dh.code AS SKU,
|
||||||
dh.description AS DESCRICAO_PRODUTO,
|
dh.description AS DESCRICAO_PRODUTO,
|
||||||
@ -364,6 +755,144 @@ def get_api_pendingorder_por_pdv() -> Dict[str, float]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_pendingorder_ignorados_por_pdv() -> Dict[str, float]:
|
||||||
|
"""Busca pendingorder ignorado por PDV na API e agrega por loja."""
|
||||||
|
try:
|
||||||
|
cache_key = "api_pendingorder_ignorados_pdv"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||||
|
token = api_config.get('TOKEN')
|
||||||
|
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_ignorados?limit=50000'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error("Erro ao chamar API pendingorder ignorados: %s", response.status_code)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
pending_ignorado_por_pdv: Dict[str, float] = {}
|
||||||
|
for row in rows:
|
||||||
|
pdv = _normalize_pdv(
|
||||||
|
row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', ''))))
|
||||||
|
)
|
||||||
|
if not pdv:
|
||||||
|
continue
|
||||||
|
pending_ignorado = _parse_float(
|
||||||
|
row.get(
|
||||||
|
'total_ignorado',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_IGNORADO',
|
||||||
|
row.get(
|
||||||
|
'total_pricesellin',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_PRICESELLIN',
|
||||||
|
row.get(
|
||||||
|
'pendingorder',
|
||||||
|
row.get('PENDINGORDER', row.get('pendente', 0))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_ignorado_por_pdv[pdv] = pending_ignorado_por_pdv.get(pdv, 0.0) + pending_ignorado
|
||||||
|
|
||||||
|
cache.set(cache_key, pending_ignorado_por_pdv, timeout=300)
|
||||||
|
return pending_ignorado_por_pdv
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Erro de conexão com API pendingorder ignorados: %s", e)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar API pendingorder ignorados: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_aprovado_hoje_total() -> float:
|
||||||
|
"""Soma o valor aprovado no dia atual (dtimplantacao == hoje) na API de detalhepedido."""
|
||||||
|
try:
|
||||||
|
hoje = timezone.localdate()
|
||||||
|
cache_key = f"api_aprovado_hoje_total:{hoje.isoformat()}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return float(cached_data)
|
||||||
|
|
||||||
|
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
|
||||||
|
token = api_config.get('TOKEN')
|
||||||
|
url = 'https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=aprovado'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error("Erro ao chamar API aprovados de hoje: %s", response.status_code)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _is_row_hoje(dt_value) -> bool:
|
||||||
|
if dt_value in (None, ''):
|
||||||
|
return False
|
||||||
|
text = str(dt_value).strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
iso_candidate = text.replace('Z', '+00:00')
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_candidate)
|
||||||
|
return dt.date() == hoje
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%d %H:%M:%S", "%d/%m/%Y %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date() == hoje
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_aprovado_hoje = 0.0
|
||||||
|
for row in rows:
|
||||||
|
if not _is_row_hoje(row.get('dtimplantacao', row.get('DTIMPLANTACAO'))):
|
||||||
|
continue
|
||||||
|
valor = _parse_float(
|
||||||
|
row.get(
|
||||||
|
'total_pricesellin',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_PRICESELLIN',
|
||||||
|
row.get(
|
||||||
|
'total_ignorado',
|
||||||
|
row.get(
|
||||||
|
'TOTAL_IGNORADO',
|
||||||
|
row.get('valor_total', row.get('VALOR_TOTAL', 0))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_aprovado_hoje += valor
|
||||||
|
|
||||||
|
cache.set(cache_key, total_aprovado_hoje, timeout=300)
|
||||||
|
return total_aprovado_hoje
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Erro de conexão com API aprovados de hoje: %s", e)
|
||||||
|
return 0.0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erro ao processar API aprovados de hoje: %s", e)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def _parse_float(value) -> float:
|
def _parse_float(value) -> float:
|
||||||
"""Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56)."""
|
"""Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56)."""
|
||||||
if value in (None, ''):
|
if value in (None, ''):
|
||||||
@ -434,6 +963,14 @@ def _normalize_pdv(value) -> str:
|
|||||||
return text or '0'
|
return text or '0'
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_marca(value) -> str:
|
||||||
|
"""Normaliza marca para comparação entre fontes diferentes."""
|
||||||
|
text = str(value or '').strip()
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
return text.upper()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sku(value) -> str:
|
def _normalize_sku(value) -> str:
|
||||||
"""Normaliza SKU para comparação entre fontes diferentes."""
|
"""Normaliza SKU para comparação entre fontes diferentes."""
|
||||||
text = str(value or '').strip()
|
text = str(value or '').strip()
|
||||||
@ -596,18 +1133,22 @@ def merge_orcamento_with_summary_data(
|
|||||||
summary_rows: List[Dict],
|
summary_rows: List[Dict],
|
||||||
saldo_por_pdv: Dict[str, float],
|
saldo_por_pdv: Dict[str, float],
|
||||||
pending_por_pdv: Optional[Dict[str, float]] = None,
|
pending_por_pdv: Optional[Dict[str, float]] = None,
|
||||||
|
pending_ignorado_por_pdv: Optional[Dict[str, float]] = None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder."""
|
"""Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder."""
|
||||||
if not summary_rows:
|
if not summary_rows:
|
||||||
return summary_rows
|
return summary_rows
|
||||||
pending_por_pdv = pending_por_pdv or {}
|
pending_por_pdv = pending_por_pdv or {}
|
||||||
|
pending_ignorado_por_pdv = pending_ignorado_por_pdv or {}
|
||||||
for row in summary_rows:
|
for row in summary_rows:
|
||||||
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
|
||||||
saldo = float(saldo_por_pdv.get(pdv, 0.0))
|
saldo = float(saldo_por_pdv.get(pdv, 0.0))
|
||||||
pending = float(pending_por_pdv.get(pdv, 0.0))
|
pending = float(pending_por_pdv.get(pdv, 0.0))
|
||||||
|
pending_ignorado = float(pending_ignorado_por_pdv.get(pdv, 0.0))
|
||||||
row['Orcamento_Bruto'] = saldo
|
row['Orcamento_Bruto'] = saldo
|
||||||
row['Pendente_API'] = pending
|
row['Pendente_API'] = pending
|
||||||
row['Orcamento_Disponivel'] = saldo - pending
|
row['Pendente_Ignorado_API'] = pending_ignorado
|
||||||
|
row['Orcamento_Disponivel'] = saldo - pending + pending_ignorado
|
||||||
return summary_rows
|
return summary_rows
|
||||||
|
|
||||||
|
|
||||||
@ -783,23 +1324,29 @@ ORDER BY pa.[DESCRIÇÃO];
|
|||||||
summary_rows = aggregate_rows_for_dashboard(rows)
|
summary_rows = aggregate_rows_for_dashboard(rows)
|
||||||
saldo_orcamento_por_pdv = get_api_orcamento_saldo_por_pdv()
|
saldo_orcamento_por_pdv = get_api_orcamento_saldo_por_pdv()
|
||||||
pending_por_pdv = get_api_pendingorder_por_pdv()
|
pending_por_pdv = get_api_pendingorder_por_pdv()
|
||||||
|
pending_ignorado_por_pdv = get_api_pendingorder_ignorados_por_pdv()
|
||||||
summary_rows = merge_orcamento_with_summary_data(
|
summary_rows = merge_orcamento_with_summary_data(
|
||||||
summary_rows,
|
summary_rows,
|
||||||
saldo_orcamento_por_pdv,
|
saldo_orcamento_por_pdv,
|
||||||
pending_por_pdv,
|
pending_por_pdv,
|
||||||
|
pending_ignorado_por_pdv,
|
||||||
)
|
)
|
||||||
|
aprovado_hoje_total = get_api_aprovado_hoje_total()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s",
|
"Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s pdvs_pending_ignorados=%s aprovado_hoje_total=%s",
|
||||||
len(rows),
|
len(rows),
|
||||||
len(summary_rows),
|
len(summary_rows),
|
||||||
len(saldo_orcamento_por_pdv),
|
len(saldo_orcamento_por_pdv),
|
||||||
len(pending_por_pdv),
|
len(pending_por_pdv),
|
||||||
|
len(pending_ignorado_por_pdv),
|
||||||
|
round(aprovado_hoje_total, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': summary_rows,
|
'data': summary_rows,
|
||||||
'count': len(summary_rows),
|
'count': len(summary_rows),
|
||||||
|
'aprovado_hoje_total': aprovado_hoje_total,
|
||||||
'api_integrated': bool(api_data),
|
'api_integrated': bool(api_data),
|
||||||
'base_pdvs_integrated': bool(base_pdvs_data),
|
'base_pdvs_integrated': bool(base_pdvs_data),
|
||||||
'orcamento_integrated': bool(saldo_orcamento_por_pdv),
|
'orcamento_integrated': bool(saldo_orcamento_por_pdv),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user