feat(dashboard): reorganiza cards, remove indicadores antigos e adiciona 'Valor médio por fatura' junto aos demais; ajusta gráfico mensal para seguir padrão de design
This commit is contained in:
229
app/main.py
229
app/main.py
@@ -6,9 +6,10 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os, shutil
|
import os, shutil
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from datetime import date
|
||||||
|
import re
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import pandas as pd
|
|
||||||
from app.models import ParametrosFormula
|
from app.models import ParametrosFormula
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
@@ -19,14 +20,12 @@ from app.processor import (
|
|||||||
status_arquivos,
|
status_arquivos,
|
||||||
limpar_arquivos_processados
|
limpar_arquivos_processados
|
||||||
)
|
)
|
||||||
from app.parametros import router as parametros_router
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from app.models import Fatura, SelicMensal, ParametrosFormula
|
from app.models import Fatura, SelicMensal, ParametrosFormula
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from app.utils import avaliar_formula
|
from app.utils import avaliar_formula
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
@@ -34,26 +33,215 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|||||||
UPLOAD_DIR = "uploads/temp"
|
UPLOAD_DIR = "uploads/temp"
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def _parse_referencia(ref: str):
|
||||||
|
"""Aceita 'JAN/2024', 'JAN/24', '01/2024', '01/24', '202401'. Retorna (ano, mes)."""
|
||||||
|
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
|
||||||
|
ref = (ref or "").strip().upper()
|
||||||
|
|
||||||
|
if "/" in ref:
|
||||||
|
a, b = [p.strip() for p in ref.split("/", 1)]
|
||||||
|
# mês pode vir 'JAN' ou '01'
|
||||||
|
mes = meses.get(a, None)
|
||||||
|
if mes is None:
|
||||||
|
mes = int(re.sub(r"\D", "", a) or 1)
|
||||||
|
ano = int(re.sub(r"\D", "", b) or 0)
|
||||||
|
# ano 2 dígitos -> 2000+
|
||||||
|
if ano < 100:
|
||||||
|
ano += 2000
|
||||||
|
else:
|
||||||
|
# '202401' ou '2024-01'
|
||||||
|
num = re.sub(r"\D", "", ref)
|
||||||
|
if len(num) >= 6:
|
||||||
|
ano, mes = int(num[:4]), int(num[4:6])
|
||||||
|
elif len(num) == 4: # '2024'
|
||||||
|
ano, mes = int(num), 1
|
||||||
|
else:
|
||||||
|
ano, mes = date.today().year, 1
|
||||||
|
return ano, mes
|
||||||
|
|
||||||
|
async def _carregar_selic_map(session):
|
||||||
|
res = await session.execute(text("SELECT ano, mes, percentual FROM faturas.selic_mensal"))
|
||||||
|
rows = res.mappings().all()
|
||||||
|
return {(int(r["ano"]), int(r["mes"])): float(r["percentual"]) for r in rows}
|
||||||
|
|
||||||
|
def _fator_selic_from_map(selic_map: dict, ano_inicio: int, mes_inicio: int, hoje: date) -> float:
|
||||||
|
try:
|
||||||
|
ano, mes = int(ano_inicio), int(mes_inicio)
|
||||||
|
except Exception:
|
||||||
|
return 1.0
|
||||||
|
if ano > hoje.year or (ano == hoje.year and mes > hoje.month):
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
fator = 1.0
|
||||||
|
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
|
||||||
|
perc = selic_map.get((ano, mes))
|
||||||
|
if perc is not None:
|
||||||
|
fator *= (1 + (perc / 100.0))
|
||||||
|
mes += 1
|
||||||
|
if mes > 12:
|
||||||
|
mes = 1
|
||||||
|
ano += 1
|
||||||
|
return fator
|
||||||
|
|
||||||
|
|
||||||
|
def _avaliar_formula(texto_formula: str | None, contexto: dict) -> float:
|
||||||
|
if not texto_formula:
|
||||||
|
return 0.0
|
||||||
|
expr = str(texto_formula)
|
||||||
|
|
||||||
|
# Substitui nomes de campos por valores numéricos (None -> 0)
|
||||||
|
for campo, valor in contexto.items():
|
||||||
|
v = valor
|
||||||
|
if v is None or v == "":
|
||||||
|
v = 0
|
||||||
|
# aceita vírgula como decimal vindo do banco
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v
|
||||||
|
expr = re.sub(rf'\b{re.escape(str(campo))}\b', str(v), expr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return float(eval(expr, {"__builtins__": {}}, {}))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def dashboard(request: Request):
|
async def dashboard(request: Request, cliente: str | None = None):
|
||||||
indicadores = [
|
print("DBG /: inicio", flush=True)
|
||||||
{"titulo": "Total de Faturas", "valor": 124},
|
try:
|
||||||
{"titulo": "Faturas com ICMS", "valor": "63%"},
|
async with AsyncSessionLocal() as session:
|
||||||
{"titulo": "Valor Total", "valor": "R$ 280.000,00"},
|
print("DBG /: abrindo sessão", flush=True)
|
||||||
]
|
|
||||||
|
|
||||||
analise_stf = {
|
r = await session.execute(text(
|
||||||
"antes": {"percentual_com_icms": 80, "media_valor": 1200},
|
"SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome"
|
||||||
"depois": {"percentual_com_icms": 20, "media_valor": 800},
|
))
|
||||||
}
|
clientes = [c for c, in r.fetchall()]
|
||||||
|
print(f"DBG /: clientes={len(clientes)}", flush=True)
|
||||||
|
|
||||||
|
# Fórmulas
|
||||||
|
fp = await session.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE LIMIT 1
|
||||||
|
"""))
|
||||||
|
formula_pis = fp.scalar_one_or_none()
|
||||||
|
fc = await session.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE LIMIT 1
|
||||||
|
"""))
|
||||||
|
formula_cofins = fc.scalar_one_or_none()
|
||||||
|
print(f"DBG /: tem_formulas pis={bool(formula_pis)} cofins={bool(formula_cofins)}", flush=True)
|
||||||
|
|
||||||
|
sql = "SELECT * FROM faturas.faturas"
|
||||||
|
params = {}
|
||||||
|
if cliente:
|
||||||
|
sql += " WHERE nome = :cliente"
|
||||||
|
params["cliente"] = cliente
|
||||||
|
print("DBG /: SQL faturas ->", sql, params, flush=True)
|
||||||
|
|
||||||
|
ftrs = (await session.execute(text(sql), params)).mappings().all()
|
||||||
|
print(f"DBG /: total_faturas={len(ftrs)}", flush=True)
|
||||||
|
|
||||||
|
# ===== KPIs e Séries para o dashboard =====
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
total_faturas = len(ftrs)
|
||||||
|
qtd_icms_na_base = 0
|
||||||
|
soma_corrigida = 0.0
|
||||||
|
hoje = date.today()
|
||||||
|
selic_map = await _carregar_selic_map(session)
|
||||||
|
|
||||||
|
# Séries e somatórios comerciais
|
||||||
|
serie_mensal = defaultdict(float) # {(ano, mes): valor_corrigido}
|
||||||
|
sum_por_dist = defaultdict(float) # {"distribuidora": valor_corrigido}
|
||||||
|
somatorio_v_total = 0.0
|
||||||
|
contagem_com_icms = 0
|
||||||
|
|
||||||
|
for f in ftrs:
|
||||||
|
ctx = dict(f)
|
||||||
|
|
||||||
|
# PIS/COFINS sobre ICMS
|
||||||
|
v_pis = _avaliar_formula(formula_pis, ctx)
|
||||||
|
v_cof = _avaliar_formula(formula_cofins, ctx)
|
||||||
|
v_total = max(0.0, float(v_pis or 0) + float(v_cof or 0))
|
||||||
|
|
||||||
|
# % de faturas com ICMS na base
|
||||||
|
if (v_pis or 0) > 0:
|
||||||
|
qtd_icms_na_base += 1
|
||||||
|
contagem_com_icms += 1
|
||||||
|
|
||||||
|
# referência -> (ano,mes)
|
||||||
|
try:
|
||||||
|
ano, mes = _parse_referencia(f.get("referencia"))
|
||||||
|
except Exception:
|
||||||
|
ano, mes = hoje.year, hoje.month
|
||||||
|
|
||||||
|
# SELIC
|
||||||
|
fator = _fator_selic_from_map(selic_map, ano, mes, hoje)
|
||||||
|
valor_corrigido = v_total * fator
|
||||||
|
|
||||||
|
soma_corrigida += valor_corrigido
|
||||||
|
somatorio_v_total += v_total
|
||||||
|
|
||||||
|
# séries
|
||||||
|
serie_mensal[(ano, mes)] += valor_corrigido
|
||||||
|
dist = (f.get("distribuidora") or "").strip() or "Não informado"
|
||||||
|
sum_por_dist[dist] += valor_corrigido
|
||||||
|
|
||||||
|
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
|
||||||
|
valor_restituicao_corrigida = soma_corrigida
|
||||||
|
valor_medio_com_icms = (somatorio_v_total / contagem_com_icms) if contagem_com_icms else 0.0
|
||||||
|
|
||||||
|
# total de clientes (distinct já carregado)
|
||||||
|
total_clientes = len(clientes)
|
||||||
|
|
||||||
|
# Série mensal – últimos 12 meses
|
||||||
|
ultimos = []
|
||||||
|
a, m = hoje.year, hoje.month
|
||||||
|
for _ in range(12):
|
||||||
|
ultimos.append((a, m))
|
||||||
|
m -= 1
|
||||||
|
if m == 0:
|
||||||
|
m = 12; a -= 1
|
||||||
|
ultimos.reverse()
|
||||||
|
|
||||||
|
serie_mensal_labels = [f"{mes:02d}/{ano}" for (ano, mes) in ultimos]
|
||||||
|
serie_mensal_valores = [round(serie_mensal.get((ano, mes), 0.0), 2) for (ano, mes) in ultimos]
|
||||||
|
|
||||||
|
# Top 5 distribuidoras
|
||||||
|
top5 = sorted(sum_por_dist.items(), key=lambda kv: kv[1], reverse=True)[:5]
|
||||||
|
top5_labels = [k for k, _ in top5]
|
||||||
|
top5_valores = [round(v, 2) for _, v in top5]
|
||||||
|
|
||||||
|
print("DBG /: calculos OK", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
print("DBG /: render template", flush=True)
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"clientes": clientes,
|
||||||
|
"cliente_atual": cliente or "",
|
||||||
|
"total_faturas": total_faturas,
|
||||||
|
"valor_restituicao_corrigida": valor_restituicao_corrigida,
|
||||||
|
"percentual_icms_base": percentual_icms_base,
|
||||||
|
|
||||||
|
# Novos dados para o template
|
||||||
|
"total_clientes": total_clientes,
|
||||||
|
"valor_medio_com_icms": valor_medio_com_icms,
|
||||||
|
"situacao_atual_percent": percentual_icms_base, # para gráfico de alerta
|
||||||
|
"serie_mensal_labels": serie_mensal_labels,
|
||||||
|
"serie_mensal_valores": serie_mensal_valores,
|
||||||
|
"top5_labels": top5_labels,
|
||||||
|
"top5_valores": top5_valores,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print("ERR /:", e, flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
# Página de erro amigável (sem derrubar servidor)
|
||||||
|
return HTMLResponse(
|
||||||
|
f"<pre style='padding:16px;color:#b91c1c;background:#fff1f2'>Falha no dashboard:\n{e}</pre>",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"cliente_atual": "",
|
|
||||||
"clientes": ["Cliente A", "Cliente B"],
|
|
||||||
"indicadores": indicadores,
|
|
||||||
"analise_stf": analise_stf
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/upload", response_class=HTMLResponse)
|
@app.get("/upload", response_class=HTMLResponse)
|
||||||
def upload_page(request: Request):
|
def upload_page(request: Request):
|
||||||
@@ -118,6 +306,7 @@ async def clear_all():
|
|||||||
|
|
||||||
@app.get("/export-excel")
|
@app.get("/export-excel")
|
||||||
async def export_excel():
|
async def export_excel():
|
||||||
|
import pandas as pd
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 1. Coletar faturas e tabela SELIC
|
# 1. Coletar faturas e tabela SELIC
|
||||||
faturas_result = await session.execute(select(Fatura))
|
faturas_result = await session.execute(select(Fatura))
|
||||||
|
|||||||
140
app/routes/dashboard_unused.py
Normal file
140
app/routes/dashboard_unused.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Usa o avaliador de fórmulas já existente
|
||||||
|
from app.utils import avaliar_formula
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
# Conexão com o banco (use a mesma DATABASE_URL do restante do app)
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
def _parse_referencia(ref: str):
|
||||||
|
"""Aceita 'JAN/2024', '01/2024' ou '202401'. Retorna (ano, mes)."""
|
||||||
|
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
|
||||||
|
ref = (ref or "").strip().upper()
|
||||||
|
if "/" in ref:
|
||||||
|
a, b = ref.split("/")
|
||||||
|
if a.isdigit():
|
||||||
|
mes, ano = int(a), int(b)
|
||||||
|
else:
|
||||||
|
mes, ano = meses.get(a, 1), int(b)
|
||||||
|
else:
|
||||||
|
ano, mes = int(ref[:4]), int(ref[4:]) if len(ref) >= 6 else 1
|
||||||
|
return ano, mes
|
||||||
|
|
||||||
|
def _fator_selic_acumulado(conn, ano_inicio, mes_inicio, hoje):
|
||||||
|
selic = conn.execute(text("""
|
||||||
|
SELECT ano, mes, percentual
|
||||||
|
FROM faturas.selic_mensal
|
||||||
|
""")).mappings().all()
|
||||||
|
selic_map = {(r["ano"], r["mes"]): float(r["percentual"]) for r in selic}
|
||||||
|
|
||||||
|
fator = 1.0
|
||||||
|
ano, mes = int(ano_inicio), int(mes_inicio)
|
||||||
|
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
|
||||||
|
perc = selic_map.get((ano, mes))
|
||||||
|
if perc is not None:
|
||||||
|
fator *= (1 + perc/100.0)
|
||||||
|
mes += 1
|
||||||
|
if mes > 12:
|
||||||
|
mes = 1; ano += 1
|
||||||
|
return fator
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, cliente: str | None = None):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Lista de clientes (distinct nome)
|
||||||
|
clientes = [r[0] for r in conn.execute(text("""
|
||||||
|
SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome
|
||||||
|
""")).fetchall()]
|
||||||
|
|
||||||
|
# Carrega fórmulas (ativas)
|
||||||
|
formula_pis = conn.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
""")).scalar_one_or_none()
|
||||||
|
|
||||||
|
formula_cofins = conn.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
""")).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Carrega faturas (com filtro opcional de cliente)
|
||||||
|
params = {}
|
||||||
|
sql = "SELECT * FROM faturas.faturas"
|
||||||
|
if cliente:
|
||||||
|
sql += " WHERE nome = :cliente"
|
||||||
|
params["cliente"] = cliente
|
||||||
|
|
||||||
|
faturas = conn.execute(text(sql), params).mappings().all()
|
||||||
|
|
||||||
|
|
||||||
|
total_faturas = len(faturas)
|
||||||
|
|
||||||
|
# Cálculos de restituição e % ICMS na base
|
||||||
|
hoje = date.today()
|
||||||
|
soma_corrigida = 0.0
|
||||||
|
qtd_icms_na_base = 0
|
||||||
|
|
||||||
|
for f in faturas:
|
||||||
|
contexto = dict(f) # usa colunas como variáveis da fórmula
|
||||||
|
# PIS sobre ICMS
|
||||||
|
v_pis_icms = avaliar_formula(formula_pis, contexto) if formula_pis else None
|
||||||
|
# COFINS sobre ICMS
|
||||||
|
v_cofins_icms = avaliar_formula(formula_cofins, contexto) if formula_cofins else None
|
||||||
|
|
||||||
|
# Contagem para % ICMS na base: considera PIS_sobre_ICMS > 0
|
||||||
|
if v_pis_icms and float(v_pis_icms) > 0:
|
||||||
|
qtd_icms_na_base += 1
|
||||||
|
|
||||||
|
# Corrigir pela SELIC desde a referência da fatura
|
||||||
|
try:
|
||||||
|
ano, mes = _parse_referencia(f.get("referencia"))
|
||||||
|
fator = _fator_selic_acumulado(conn, ano, mes, hoje)
|
||||||
|
except Exception:
|
||||||
|
fator = 1.0
|
||||||
|
|
||||||
|
valor_bruto = (float(v_pis_icms) if v_pis_icms else 0.0) + (float(v_cofins_icms) if v_cofins_icms else 0.0)
|
||||||
|
soma_corrigida += valor_bruto * fator
|
||||||
|
|
||||||
|
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
|
||||||
|
valor_restituicao_corrigida = soma_corrigida
|
||||||
|
|
||||||
|
# --- Análise STF (mantida) ---
|
||||||
|
def media_percentual_icms(inicio: str, fim: str):
|
||||||
|
# Aproximação: base PIS = base ICMS => configurado como proxy “com ICMS na base”
|
||||||
|
q = text(f"""
|
||||||
|
SELECT
|
||||||
|
ROUND(AVG(CASE WHEN icms_base IS NOT NULL AND pis_base = icms_base THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
|
||||||
|
ROUND(AVG(COALESCE(pis_valor,0) + COALESCE(cofins_valor,0)), 2) AS media_valor
|
||||||
|
FROM faturas.faturas
|
||||||
|
WHERE data_processamento::date BETWEEN :inicio AND :fim
|
||||||
|
{ "AND nome = :cliente" if cliente else "" }
|
||||||
|
""")
|
||||||
|
params = {"inicio": inicio, "fim": fim}
|
||||||
|
if cliente: params["cliente"] = cliente
|
||||||
|
r = conn.execute(q, params).mappings().first() or {}
|
||||||
|
return {"percentual_com_icms": r.get("percentual_com_icms", 0), "media_valor": r.get("media_valor", 0)}
|
||||||
|
|
||||||
|
analise_stf = {
|
||||||
|
"antes": media_percentual_icms("2000-01-01", "2017-03-15"),
|
||||||
|
"depois": media_percentual_icms("2017-03-16", "2099-12-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"clientes": clientes,
|
||||||
|
"cliente_atual": cliente or "",
|
||||||
|
"total_faturas": total_faturas,
|
||||||
|
"valor_restituicao_corrigida": valor_restituicao_corrigida,
|
||||||
|
"percentual_icms_base": percentual_icms_base,
|
||||||
|
"analise_stf": analise_stf
|
||||||
|
})
|
||||||
@@ -2,106 +2,254 @@
|
|||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 style="display: flex; align-items: center; gap: 10px;">
|
<div id="loading" class="loading-backdrop">
|
||||||
<i class="fas fa-chart-line"></i> Dashboard de Faturas
|
<div class="spinner"></div>
|
||||||
</h1>
|
<div class="loading-msg">Carregando dados…</div>
|
||||||
|
|
||||||
<form method="get" style="margin: 20px 0;">
|
|
||||||
<label for="cliente">Selecionar Cliente:</label>
|
|
||||||
<select name="cliente" id="cliente" onchange="this.form.submit()">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
{% for c in clientes %}
|
|
||||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Cards -->
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 30px;">
|
|
||||||
{% for indicador in indicadores %}
|
|
||||||
<div style="
|
|
||||||
flex: 1 1 220px;
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
|
||||||
">
|
|
||||||
<strong>{{ indicador.titulo }}</strong>
|
|
||||||
<div style="font-size: 1.6rem; font-weight: bold; margin-top: 10px;">
|
|
||||||
{{ indicador.valor }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style="margin-bottom: 20px;"><i class="fas fa-chart-bar"></i> Análise da Decisão do STF (RE 574.706 – 15/03/2017)</h2>
|
|
||||||
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 20px;">
|
<style>
|
||||||
<div style="flex: 1;">
|
/* ---- Combobox estilizado ---- */
|
||||||
<h4>% de Faturas com ICMS na Base PIS/COFINS</h4>
|
.combo {
|
||||||
<canvas id="graficoICMS"></canvas>
|
appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 44px 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #111827;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.06);
|
||||||
|
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
.combo:focus { outline: none; border-color: #2563eb; box-shadow: 0 8px 28px rgba(37,99,235,.18); }
|
||||||
|
.combo-wrap { position: relative; display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.combo-wrap:after {
|
||||||
|
content: "▾"; position: absolute; right: 12px; pointer-events: none; color:#6b7280; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cards ---- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 18px; margin: 22px 0 32px; }
|
||||||
|
.card {
|
||||||
|
grid-column: span 12;
|
||||||
|
display: grid; grid-template-columns: 82px 1fr; align-items: start;
|
||||||
|
background: #1f2937; /* cinza escuro */
|
||||||
|
color: #f9fafb; /* texto claro */
|
||||||
|
border-radius: 18px; padding: 18px;
|
||||||
|
box-shadow: 0 12px 34px rgba(0,0,0,.08);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease;
|
||||||
|
animation: pop .35s ease both;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(0,0,0,.1); }
|
||||||
|
@keyframes pop { from{ transform: scale(.98); opacity:.0 } to{ transform: scale(1); opacity:1 } }
|
||||||
|
|
||||||
|
.card .icon {
|
||||||
|
width: 72px; height: 72px; border-radius: 16px;
|
||||||
|
display: grid; place-items: center; font-size: 38px; color: #fff;
|
||||||
|
box-shadow: inset 0 0 40px rgba(255,255,255,.2);
|
||||||
|
}
|
||||||
|
.icon.blue { background: linear-gradient(135deg, #2563eb, #3b82f6); }
|
||||||
|
.icon.green { background: linear-gradient(135deg, #059669, #10b981); }
|
||||||
|
.icon.amber { background: linear-gradient(135deg, #d97706, #f59e0b); }
|
||||||
|
|
||||||
|
.metrics { padding-left: 16px; }
|
||||||
|
.value { font-size: 30px; font-weight: 800; color: #f9fafb; text-align: right; }
|
||||||
|
.label { margin-top: 6px; font-size: 13px; color: #d1d5db; text-align: right; }
|
||||||
|
|
||||||
|
/* Responsivo */
|
||||||
|
@media (min-width: 640px) { .card { grid-column: span 6; } }
|
||||||
|
@media (min-width: 1024px){ .card { grid-column: span 4; } }
|
||||||
|
|
||||||
|
.loading-backdrop{
|
||||||
|
position:fixed; inset:0; z-index:9999;
|
||||||
|
background:rgba(17,24,39,.55); backdrop-filter: blur(2px);
|
||||||
|
display:flex; align-items:center; justify-content:center; gap:12px;
|
||||||
|
transition:opacity .25s ease; opacity:1; pointer-events:auto;
|
||||||
|
}
|
||||||
|
.loading-backdrop.hide{ opacity:0; pointer-events:none; }
|
||||||
|
.spinner{
|
||||||
|
width:40px; height:40px; border:4px solid rgba(255,255,255,.3);
|
||||||
|
border-top-color:#60a5fa; border-radius:50%; animation:spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin{ to{ transform:rotate(360deg) } }
|
||||||
|
.loading-msg{ color:#fff; font-weight:600; }
|
||||||
|
|
||||||
|
/* Card simples para gráficos */
|
||||||
|
.panel{
|
||||||
|
background:#1f2937; /* mesmo fundo dos cards */
|
||||||
|
color:#f9fafb;
|
||||||
|
border-radius:18px;
|
||||||
|
padding:16px 18px 22px;
|
||||||
|
box-shadow:0 12px 34px rgba(0,0,0,.08);
|
||||||
|
margin-top:10px;
|
||||||
|
}
|
||||||
|
.panel-title{
|
||||||
|
margin:0 0 10px 0;
|
||||||
|
font-weight:700;
|
||||||
|
display:flex;align-items:center;gap:10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1 style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||||
|
<i class="fas fa-chart-line"></i> Dashboard de Faturas
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form method="get" action="/" style="margin: 6px 0 18px">
|
||||||
|
<div class="combo-wrap">
|
||||||
|
<label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||||
|
<select class="combo" name="cliente" id="cliente">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{% for c in clientes %}
|
||||||
|
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1;">
|
</form>
|
||||||
<h4>Valor Médio de Tributos com ICMS</h4>
|
|
||||||
<canvas id="graficoValor"></canvas>
|
<script>
|
||||||
|
document.getElementById('cliente').addEventListener('change', function () {
|
||||||
|
const u = new URL(window.location);
|
||||||
|
if (this.value) u.searchParams.set('cliente', this.value);
|
||||||
|
else u.searchParams.delete('cliente');
|
||||||
|
u.pathname = "/"; // garante que fica na raiz
|
||||||
|
window.location = u.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Cards -->
|
||||||
|
<div class="cards">
|
||||||
|
<!-- Total de Clientes -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" style="background: linear-gradient(135deg,#7c3aed,#a78bfa)"><i class="fas fa-users"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:,}'.format(total_clientes or 0).replace(',', '.') }}</div>
|
||||||
|
<div class="label">Total de clientes</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Total de Faturas -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon blue"><i class="fas fa-file-invoice"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:,}'.format(total_faturas or 0).replace(',', '.') }}</div>
|
||||||
|
<div class="label">Total de faturas processadas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restituição Corrigida -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon green"><i class="fas fa-hand-holding-usd"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">R$ {{ '{:,.2f}'.format(valor_restituicao_corrigida or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
|
||||||
|
<div class="label">Restituição corrigida (PIS+COFINS sobre ICMS)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- % ICMS na Base -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon amber"><i class="fas fa-percentage"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:.1f}%'.format(percentual_icms_base or 0) }}</div>
|
||||||
|
<div class="label">% de faturas com ICMS na base do PIS/COFINS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valor médio por fatura com ICMS na base -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" style="background: linear-gradient(135deg,#ef4444,#f97316)">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">R$ {{ '{:,.2f}'.format(valor_medio_com_icms or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
|
||||||
|
<div class="label">Valor médio (PIS+COFINS sobre ICMS) por fatura</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evolução mensal (card) -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2 class="panel-title">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
Evolução mensal do valor passível de recuperação
|
||||||
|
</h2>
|
||||||
|
<canvas id="graficoEvolucao" style="max-height:360px"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||||
<script>
|
<script>
|
||||||
const ctx1 = document.getElementById('graficoICMS').getContext('2d');
|
const ctxE = document.getElementById('graficoEvolucao').getContext('2d');
|
||||||
new Chart(ctx1, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['Antes da Decisão', 'Depois da Decisão'],
|
|
||||||
datasets: [{
|
|
||||||
label: '% com ICMS na Base',
|
|
||||||
data: {{ [analise_stf.antes.percentual_com_icms, analise_stf.depois.percentual_com_icms] | tojson }},
|
|
||||||
backgroundColor: ['#f39c12', '#e74c3c']
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: true },
|
|
||||||
title: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
title: { display: true, text: '%' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctx2 = document.getElementById('graficoValor').getContext('2d');
|
const evoLabels = {{ serie_mensal_labels | tojson }};
|
||||||
new Chart(ctx2, {
|
const evoValores = {{ serie_mensal_valores | tojson }};
|
||||||
type: 'bar',
|
|
||||||
data: {
|
new Chart(ctxE, {
|
||||||
labels: ['Antes da Decisão', 'Depois da Decisão'],
|
type: 'line',
|
||||||
datasets: [{
|
data: {
|
||||||
label: 'Valor Médio de PIS/COFINS com ICMS',
|
labels: evoLabels,
|
||||||
data: {{ [analise_stf.antes.media_valor, analise_stf.depois.media_valor] | tojson }},
|
datasets: [{
|
||||||
backgroundColor: ['#2980b9', '#27ae60']
|
label: 'Valor corrigido (R$)',
|
||||||
}]
|
data: evoValores,
|
||||||
},
|
fill: false,
|
||||||
options: {
|
tension: 0.25,
|
||||||
responsive: true,
|
borderWidth: 3,
|
||||||
plugins: {
|
pointRadius: 4,
|
||||||
legend: { display: true },
|
pointHoverRadius: 5
|
||||||
title: { display: false }
|
}]
|
||||||
},
|
},
|
||||||
scales: {
|
options: {
|
||||||
y: {
|
responsive: true,
|
||||||
beginAtZero: true,
|
plugins: {
|
||||||
title: { display: true, text: 'R$' }
|
legend: {
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true, // legenda com “linha”, não retângulo
|
||||||
|
pointStyle: 'line'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
align: 'top',
|
||||||
|
anchor: 'end',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
font: { weight: 600, size: 11 },
|
||||||
|
formatter: (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const v = ctx.parsed.y ?? 0;
|
||||||
|
return 'R$ ' + Number(v).toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } }, // remove linhas do fundo
|
||||||
|
y: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { callback: v => 'R$ ' + Number(v).toLocaleString('pt-BR') }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
plugins: [ChartDataLabels] // ativa o plugin de rótulos
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mostra overlay ao iniciar; esconde quando tudo carregar
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const el = document.getElementById('loading');
|
||||||
|
// garante visível até 'load'
|
||||||
|
el.classList.remove('hide');
|
||||||
|
});
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const el = document.getElementById('loading');
|
||||||
|
el.classList.add('hide');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a href="/export-excel{% if cliente_atual %}?cliente={{ cliente_atual }}{% endif %}" class="btn btn-primary">
|
||||||
|
📥 Baixar Relatório Corrigido (Excel)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #2563eb; color: white;">
|
<tr style="background: #2563eb; color: white;">
|
||||||
|
|||||||
Reference in New Issue
Block a user