diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 3f5d162..f135b03 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -195,6 +195,7 @@ def listar( primeiro_item = offset + 1 if total > 0 else 0 ultimo_item = min(offset + por_pagina_efetivo, total) + redirecionar_para = request.url.path + (f"?{request.url.query}" if request.url.query else "") resposta = templates.TemplateResponse( request, @@ -207,6 +208,7 @@ def listar( "primeiro_item": primeiro_item, "ultimo_item": ultimo_item, "janela_paginas": _janela_paginas(pagina, total_paginas), "csrf_token": token_do_contexto(request), + "redirecionar_para": redirecionar_para, }, ) if por_pagina in OPCOES_POR_PAGINA: @@ -408,3 +410,16 @@ def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): db_log.registrar(usuario, "excluir", "cliente", str(cliente_id), True) return _redirect_com_mensagem("/clientes", "Cliente excluído.") + + +@router.post("/{cliente_id}/alternar-ativo", dependencies=[Depends(exigir_csrf)]) +def alternar_ativo(cliente_id: int, redirecionar_para: str = Form(""), usuario: str = Depends(exigir_login)): + destino = redirecionar_para if redirecionar_para.startswith("/clientes") else "/clientes" + + alternado = db_clientes.alternar_ativo(cliente_id) + if not alternado: + db_log.registrar(usuario, "alternar_ativo", "cliente", str(cliente_id), False, "cliente não encontrado") + return _redirect_com_mensagem(destino, "Cliente não encontrado.", "danger") + + db_log.registrar(usuario, "alternar_ativo", "cliente", str(cliente_id), True) + return _redirect_com_mensagem(destino, "Status do cliente atualizado.") diff --git a/app/static/estilo.css b/app/static/estilo.css index dc37a7d..9efd0dd 100644 --- a/app/static/estilo.css +++ b/app/static/estilo.css @@ -246,6 +246,12 @@ a.botao:hover { border-color: var(--cor-acento); color: var(--cor-acento); } .chave-feedback-erro { color: var(--cor-perigo); } .botao-gerar-chave { margin-left: 6px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +.status-dot { display: inline-block; width: 12px; height: 12px; padding: 0; margin: 0; border: none; border-radius: 50%; cursor: pointer; vertical-align: middle; } +.status-dot-ativo { background: var(--cor-sucesso); } +.status-dot-inativo { background: var(--cor-perigo); } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } } diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 17b5d82..9e76ab9 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -37,6 +37,7 @@ {% endfor %} + Status {% for c in clientes %} @@ -45,6 +46,20 @@ {{ c.RAZAO }} {{ c.FANTASIA }} {{ c.CNPJ }} + +
+ + + +
+ Abrir diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 81b92af..daf8175 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -715,6 +715,73 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): assert "tem contato" in resposta.text +def test_alternar_ativo_inverte_e_redireciona_para_url_informada(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True) + resposta = client.post( + "/clientes/1/alternar-ativo", + data={"csrf_token": CSRF_TESTE, "redirecionar_para": "/clientes?status=todos&pagina=3"}, + ) + assert resposta.status_code == 303 + assert resposta.headers["location"].startswith("/clientes?status=todos&pagina=3") + + +def test_alternar_ativo_redirecionar_para_invalido_cai_no_clientes_generico(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True) + resposta = client.post( + "/clientes/1/alternar-ativo", + data={"csrf_token": CSRF_TESTE, "redirecionar_para": "https://malicioso.example/"}, + ) + assert resposta.status_code == 303 + assert resposta.headers["location"].startswith("/clientes?") + assert "malicioso" not in resposta.headers["location"] + + +def test_alternar_ativo_sem_redirecionar_para_cai_no_clientes_generico(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True) + resposta = client.post("/clientes/1/alternar-ativo", data={"csrf_token": CSRF_TESTE}) + assert resposta.status_code == 303 + assert resposta.headers["location"].startswith("/clientes?") + + +def test_alternar_ativo_id_inexistente_nao_finge_sucesso(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: False) + resposta = client.post("/clientes/999/alternar-ativo", data={"csrf_token": CSRF_TESTE}) + assert resposta.status_code == 303 + assert "n%C3%A3o+encontrado" in resposta.headers["location"] or "nao encontrado" in resposta.headers["location"] + + +def test_listar_clientes_mostra_bolinha_de_status(monkeypatch): + monkeypatch.setattr( + router_clientes.db_clientes, + "listar_clientes", + lambda **kwargs: [ + {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Ativo", "FANTASIA": "A", "CNPJ": "1", "ATIVO": 1}, + {"ID": 2, "CODIGO": "000002", "RAZAO": "Cliente Inativo", "FANTASIA": "I", "CNPJ": "2", "ATIVO": 0}, + ], + ) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 2) + + resposta = client.get("/clientes?status=todos") + assert resposta.status_code == 200 + assert "status-dot-ativo" in resposta.text + assert "status-dot-inativo" in resposta.text + assert '/clientes/1/alternar-ativo' in resposta.text + assert '/clientes/2/alternar-ativo' in resposta.text + + +def test_listar_clientes_redirecionar_para_reflete_url_atual(monkeypatch): + monkeypatch.setattr( + router_clientes.db_clientes, + "listar_clientes", + lambda **kwargs: [{"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "A", "CNPJ": "1", "ATIVO": 1}], + ) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 1) + + resposta = client.get("/clientes?status=inativos&pagina=2&por_pagina=50") + assert resposta.status_code == 200 + assert 'value="/clientes?status=inativos&pagina=2&por_pagina=50"' in resposta.text + + def _limpar_codigo(codigo: str) -> None: conn = get_connection() try: