Compare commits

...

2 Commits

5 changed files with 593 additions and 199 deletions

View File

@@ -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))

View File

@@ -1,90 +0,0 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, text
import os
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Conexão com o banco de dados PostgreSQL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/faturas")
engine = create_engine(DATABASE_URL)
@router.get("/dashboard")
def dashboard(request: Request, cliente: str = None):
with engine.connect() as conn:
filtros = ""
if cliente:
filtros = "WHERE nome = :cliente"
# Clientes únicos
clientes_query = text("SELECT DISTINCT nome FROM faturas ORDER BY nome")
clientes = [row[0] for row in conn.execute(clientes_query)]
# Indicadores
indicadores = []
indicadores.append({
"titulo": "Faturas com erro",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE erro IS TRUE {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com valor total igual a R$ 0",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE total = 0 {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Clientes únicos",
"valor": conn.execute(text(f"SELECT COUNT(DISTINCT nome) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Total de faturas",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com campos nulos",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE base_pis IS NULL OR base_cofins IS NULL OR base_icms IS NULL {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Alíquotas zeradas com valores diferentes de zero",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE (aliq_pis = 0 AND pis > 0) OR (aliq_cofins = 0 AND cofins > 0) {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com ICMS incluso após decisão STF",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE data_emissao > '2017-03-15' AND base_pis = base_icms {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Valor total processado",
"valor": conn.execute(text(f"SELECT ROUND(SUM(total), 2) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar() or 0
})
# Análise do STF
def media_percentual_icms(data_inicio, data_fim):
result = conn.execute(text(f"""
SELECT
ROUND(AVG(CASE WHEN base_pis = base_icms THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
ROUND(AVG(pis + cofins), 2) AS media_valor
FROM faturas
WHERE data_emissao BETWEEN :inicio AND :fim
{f'AND nome = :cliente' if cliente else ''}
"""), {"inicio": data_inicio, "fim": data_fim, "cliente": cliente} if cliente else {"inicio": data_inicio, "fim": data_fim}).mappings().first()
return result or {"percentual_com_icms": 0, "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,
"indicadores": indicadores,
"analise_stf": analise_stf
})

View 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
})

View File

@@ -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 %}

View File

@@ -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;">