From c9abc47babad8268999de9dab14d3792bf27a6e4 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Magalhaes Pereira Date: Thu, 21 May 2026 01:49:51 +0000 Subject: [PATCH 01/32] feat(api): overhaul for enterprise performance, resilience and professional dashboard - Implement RESTful API versioning (/api/v1/public) - Add Redis-based analytical cache for BOM and ABC Curve - Introduce Sandbox mode for non-destructive ETL testing - Secure public routes with Kong Gateway limits and CORS - Optimize database queries using indexed date ranges - Build professional Tool-Landing Page with multi-format exports --- Dockerfile | 8 +- Makefile | 12 +- api/cache_utils.py | 63 +++ api/celery_config.py | 32 +- api/config.py | 39 +- api/crud.py | 329 ++++-------- api/main.py | 112 +++- api/sandbox_utils.py | 11 + api/tasks.py | 47 +- demo/README.md | 102 ++++ demo/css/01-variables.css | 98 ++++ demo/css/02-reset.css | 34 ++ demo/css/03-navbar.css | 182 +++++++ demo/css/04-hero.css | 186 +++++++ demo/css/05-search.css | 88 +++ demo/css/06-results.css | 333 ++++++++++++ demo/css/07-charts.css | 84 +++ demo/css/08-compare.css | 49 ++ demo/css/09-modal.css | 72 +++ demo/css/10-footer.css | 118 +++++ demo/css/11-toast.css | 21 + demo/css/12-utilities.css | 3 + demo/css/13-responsive.css | 170 ++++++ demo/index.html | 501 ++++++++++++++++++ demo/js/api.js | 77 +++ demo/js/config.js | 13 + demo/js/dom.js | 73 +++ demo/js/events.js | 118 +++++ demo/js/main.js | 52 ++ demo/js/modules/abc.js | 131 +++++ demo/js/modules/compare.js | 111 ++++ demo/js/modules/modal.js | 137 +++++ demo/js/modules/search.js | 94 ++++ demo/js/state.js | 11 + demo/js/theme.js | 39 ++ demo/js/toast.js | 12 + demo/js/utils.js | 101 ++++ demo/style.css | 18 + demo/tests.js | 276 ++++++++++ docker-compose.yml | 9 +- docs/AUDIT_AND_REFACTOR_PLAN_20260518.md | 63 +++ docs/FINAL_MODERNIZATION_REPORT_20260518.md | 47 ++ docs/URGENT_SERVER_OVERLOAD_FIX.md | 219 ++++++++ docs/plans/DASHBOARD_DEMO_PLAN.md | 33 ++ ...T_202605_AUTOSINAPI_PROFESSIONALIZATION.md | 43 ++ kong/kong.yml | 54 +- tests/test_cache.py | 49 ++ tests/test_etl_integration.py | 51 ++ 48 files changed, 4242 insertions(+), 283 deletions(-) create mode 100644 api/cache_utils.py create mode 100644 api/sandbox_utils.py create mode 100644 demo/README.md create mode 100644 demo/css/01-variables.css create mode 100644 demo/css/02-reset.css create mode 100644 demo/css/03-navbar.css create mode 100644 demo/css/04-hero.css create mode 100644 demo/css/05-search.css create mode 100644 demo/css/06-results.css create mode 100644 demo/css/07-charts.css create mode 100644 demo/css/08-compare.css create mode 100644 demo/css/09-modal.css create mode 100644 demo/css/10-footer.css create mode 100644 demo/css/11-toast.css create mode 100644 demo/css/12-utilities.css create mode 100644 demo/css/13-responsive.css create mode 100644 demo/index.html create mode 100644 demo/js/api.js create mode 100644 demo/js/config.js create mode 100644 demo/js/dom.js create mode 100644 demo/js/events.js create mode 100644 demo/js/main.js create mode 100644 demo/js/modules/abc.js create mode 100644 demo/js/modules/compare.js create mode 100644 demo/js/modules/modal.js create mode 100644 demo/js/modules/search.js create mode 100644 demo/js/state.js create mode 100644 demo/js/theme.js create mode 100644 demo/js/toast.js create mode 100644 demo/js/utils.js create mode 100644 demo/style.css create mode 100644 demo/tests.js create mode 100644 docs/AUDIT_AND_REFACTOR_PLAN_20260518.md create mode 100644 docs/FINAL_MODERNIZATION_REPORT_20260518.md create mode 100644 docs/URGENT_SERVER_OVERLOAD_FIX.md create mode 100644 docs/plans/DASHBOARD_DEMO_PLAN.md create mode 100644 docs/workplans/SPRINT_202605_AUTOSINAPI_PROFESSIONALIZATION.md create mode 100644 tests/test_cache.py create mode 100644 tests/test_etl_integration.py diff --git a/Dockerfile b/Dockerfile index 61162cb..4b4e5e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,13 @@ RUN apt-get update && \ # Estágio 3: Cópia do código da aplicação COPY ./api /app/api -# Estágio 4: Comando de execução +# Estágio 4: Segurança e Execução +RUN apt-get update && apt-get install -y wget procps --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* && \ + useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app +USER appuser + # Expõe a porta que o Uvicorn usará EXPOSE 8000 # Inicia o servidor Uvicorn diff --git a/Makefile b/Makefile index adcff98..ae28d96 100644 --- a/Makefile +++ b/Makefile @@ -3,24 +3,24 @@ up: @echo "Iniciando os containers Docker em modo detached..." - docker-compose up --build -d + docker compose up --build -d down: @echo "Parando e removendo os containers, redes e volumes..." - docker-compose down -v + docker compose down -v populate-db: @echo "Disparando a tarefa de ETL para popular o banco de dados..." - docker-compose exec api python -c "import os; from api.tasks import populate_sinapi_task; db_config = {'host': os.getenv('POSTGRES_HOST', 'db'), 'port': int(os.getenv('POSTGRES_PORT', 5432)), 'database': os.getenv('POSTGRES_DB'), 'user': os.getenv('POSTGRES_USER'), 'password': os.getenv('POSTGRES_PASSWORD')}; sinapi_config = {'year': 2025, 'month': 7}; populate_sinapi_task.delay(db_config, sinapi_config)" + docker compose exec api python -c "import os; from api.tasks import populate_sinapi_task; db_config = {'host': os.getenv('POSTGRES_HOST', 'db'), 'port': int(os.getenv('POSTGRES_PORT', 5432)), 'database': os.getenv('POSTGRES_DB'), 'user': os.getenv('POSTGRES_USER'), 'password': os.getenv('POSTGRES_PASSWORD')}; sinapi_config = {'year': 2025, 'month': 7}; populate_sinapi_task.delay(db_config, sinapi_config)" logs-api: @echo "Exibindo logs do container da API..." - docker-compose logs -f api + docker compose logs -f api logs-kong: @echo "Exibindo logs do container do Kong..." - docker-compose logs -f kong + docker compose logs -f kong status: @echo "Verificando o status dos containers..." - docker-compose ps + docker compose ps diff --git a/api/cache_utils.py b/api/cache_utils.py new file mode 100644 index 0000000..d7b57bb --- /dev/null +++ b/api/cache_utils.py @@ -0,0 +1,63 @@ +import json +import redis +import functools +import logging +from .config import settings +from .sandbox_utils import is_sandbox_mode + +logger = logging.getLogger(__name__) + +# Cliente Redis centralizado +redis_client = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=0, + decode_responses=True +) + +def cache_result(ttl: int = settings.CACHE_DEFAULT_TTL): + """ + Decorator para cachear resultados de funções analíticas. + Converte Rows do SQLAlchemy em dicts para serialização JSON. + """ + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Gerar chave de cache baseada nos argumentos (exceto a sessão do DB) + # args[0] costuma ser 'db: Session' + cache_args = args[1:] + sandbox_suffix = "sandbox" if is_sandbox_mode() else "prod" + + key = f"cache:{func.__name__}:{sandbox_suffix}:{':'.join(map(str, cache_args))}:{':'.join(f'{k}={v}' for k, v in kwargs.items())}" + + try: + cached_val = redis_client.get(key) + if cached_val: + logger.debug(f"Cache HIT for {key}") + return json.loads(cached_val) + except Exception as e: + logger.warning(f"Erro ao ler cache no Redis: {e}") + + # Executa a função real + result = func(*args, **kwargs) + + # Converte resultado (Row ou List[Row]) para dict serializável + serializable_result = [] + if result is not None: + if isinstance(result, list): + # fetchall() retorna lista de objetos que se comportam como dict ou Row + # No SQLAlchemy 2.0+, Rows podem ser convertidos via _asdict() + serializable_result = [dict(row._mapping) if hasattr(row, '_mapping') else row for row in result] + else: + # first() retorna um único Row + serializable_result = dict(result._mapping) if hasattr(result, '_mapping') else result + + try: + redis_client.set(key, json.dumps(serializable_result), ex=ttl) + logger.debug(f"Cache MISS for {key}. Stored result.") + except Exception as e: + logger.warning(f"Erro ao salvar no cache Redis: {e}") + + return serializable_result + return wrapper + return decorator diff --git a/api/celery_config.py b/api/celery_config.py index 3a5295c..c38d0fa 100644 --- a/api/celery_config.py +++ b/api/celery_config.py @@ -13,6 +13,34 @@ onde o Celery armazena o estado e o resultado das tarefas executadas. """ +import os + # Configurações para o Celery -broker_url = 'redis://redis:6379/0' # Aponta para o serviço 'redis' do Docker Compose [cite: 7] -result_backend = 'redis://redis:6379/0' # Aponta para o serviço 'redis' do Docker Compose [cite: 7] \ No newline at end of file +# Utiliza REDIS_HOST do ambiente ou fallback para o nome único da stack +redis_host = os.getenv("REDIS_HOST", "autosinapi_redis") +broker_url = f'redis://{redis_host}:6379/0' +result_backend = f'redis://{redis_host}:6379/0' + +# --- Limites de Concorrência e Sobrecarga --- +# Máximo 1 tarefa por worker (ETL do SINAPI é pesada e consome muita RAM) +worker_concurrency = 1 + +# Recicla o processo worker a cada 10 tarefas para evitar vazamentos de memória (comum com Pandas/Openpyxl) +worker_max_tasks_per_child = 10 + +# Pre-fetch de apenas 1 tarefa por vez +worker_prefetch_multiplier = 1 + +# --- Resiliência e Confirmação --- +# Acknowledge apenas após a conclusão da tarefa (evita perda se o container cair no meio) +task_acks_late = True + +# Se o worker sumir (ex: OOM Kill), a tarefa é rejeitada e pode ser re-enfileirada +task_reject_on_worker_lost = True + +# --- Timeouts (Defesa contra tarefas travadas) --- +# Hard kill após 90 minutos (Ingestão nacional de SP é pesada) +task_time_limit = 5400 + +# Soft timeout (lança exception SoftTimeLimitExceeded) após 60 minutos +task_soft_time_limit = 3600 \ No newline at end of file diff --git a/api/config.py b/api/config.py index 952b1d9..8d84e2c 100644 --- a/api/config.py +++ b/api/config.py @@ -11,32 +11,51 @@ O arquivo `.env` é lido automaticamente. """ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .sandbox_utils import get_sandbox_table_name class Settings(BaseSettings): """ Gerencia as configurações da aplicação, lendo de variáveis de ambiente. """ + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + # --- Configurações do Banco de Dados --- # A URL de conexão completa, lida diretamente do .env DATABASE_URL: str # --- Nomes de Tabelas e Views (centralizando "magic strings") --- # Permite alterar nomes de tabelas em um único lugar, se necessário. - TABLE_INSUMOS: str = "insumos" - TABLE_COMPOSICOES: str = "composicoes" - TABLE_PRECOS_INSUMOS: str = "precos_insumos_mensal" - TABLE_CUSTOS_COMPOSICOES: str = "custos_composicoes_mensal" - VIEW_COMPOSICAO_ITENS: str = "vw_composicao_itens_unificados" + @property + def TABLE_INSUMOS(self) -> str: + return get_sandbox_table_name("insumos") + + @property + def TABLE_COMPOSICOES(self) -> str: + return get_sandbox_table_name("composicoes") + + @property + def TABLE_PRECOS_INSUMOS(self) -> str: + return get_sandbox_table_name("precos_insumos_mensal") + + @property + def TABLE_CUSTOS_COMPOSICOES(self) -> str: + return get_sandbox_table_name("custos_composicoes_mensal") + + @property + def VIEW_COMPOSICAO_ITENS(self) -> str: + return get_sandbox_table_name("vw_composicao_itens_unificados") + + # --- Configurações de Cache --- + REDIS_HOST: str = "redis" + REDIS_PORT: int = 6379 + CACHE_DEFAULT_TTL: int = 86400 # 24 horas # --- Constantes de Negócio --- # Centraliza valores padrão usados nas queries. DEFAULT_ITEM_STATUS: str = "ATIVO" - class Config: - # Pede ao Pydantic para carregar as variáveis de um arquivo .env - env_file = ".env" - # Cria uma instância única das configurações que será importada pelo resto da aplicação. # Isso garante que as configurações sejam lidas e validadas uma única vez. settings = Settings() \ No newline at end of file diff --git a/api/crud.py b/api/crud.py index 1e30aa9..e56cac4 100644 --- a/api/crud.py +++ b/api/crud.py @@ -1,44 +1,69 @@ -# api/crud.py (versão refatorada e expandida com BI) -""" -Módulo CRUD (Create, Read, Update, Delete) e de Business Intelligence para a API. - -Este módulo contém as funções de acesso ao banco de dados, abstraindo as queries -SQL. Ele é dividido em duas seções: -1. Funções de busca direta (CRUD). -2. Funções de análise e Business Intelligence (BI). - -Utiliza o módulo de configuração para obter nomes de tabelas e constantes. -""" import pandas as pd +import calendar from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional +from datetime import datetime, date # Importa a instância única de configurações from .config import settings +from .cache_utils import cache_result + +def _get_date_range(data_referencia: str): + """ + Converte 'AAAA-MM' em um range de início e fim de mês para query indexada. + """ + try: + ref_date = datetime.strptime(data_referencia, "%Y-%m") + start_date = ref_date.replace(day=1).date() + last_day = calendar.monthrange(start_date.year, start_date.month)[1] + end_date = start_date.replace(day=last_day) + return start_date, end_date + except (ValueError, TypeError): + return None, None + +@cache_result(ttl=3600) +def get_global_stats(db: Session) -> dict: + """ + Retorna a volumetria global do banco de dados. + """ + queries = { + "insumos": text(f"SELECT count(*) FROM {settings.TABLE_INSUMOS}"), + "composicoes": text(f"SELECT count(*) FROM {settings.TABLE_COMPOSICOES}"), + "precos": text(f"SELECT count(*) FROM {settings.TABLE_PRECOS_INSUMOS}"), + "custos": text(f"SELECT count(*) FROM {settings.TABLE_CUSTOS_COMPOSICOES}") + } + stats = {} + for key, q in queries.items(): + stats[key] = db.execute(q).scalar() + return stats + +@cache_result(ttl=86400) +def get_available_filters(db: Session) -> dict: + """ + Retorna os UFs, Regimes e Datas de Referência disponíveis no banco de dados. + """ + ufs = db.execute(text(f"SELECT DISTINCT uf FROM {settings.TABLE_PRECOS_INSUMOS} ORDER BY uf")).scalars().all() + datas = db.execute(text(f"SELECT DISTINCT TO_CHAR(data_referencia, 'YYYY-MM') FROM {settings.TABLE_PRECOS_INSUMOS} ORDER BY 1 DESC")).scalars().all() + regimes = db.execute(text(f"SELECT DISTINCT regime FROM {settings.TABLE_PRECOS_INSUMOS} ORDER BY regime")).scalars().all() + return {"ufs": ufs, "datas": datas, "regimes": regimes} # --- Seção 1: Funções de Busca Direta (CRUD) --- def get_insumo_by_codigo( db: Session, codigo: int, uf: str, data_referencia: str, regime: str ) -> Optional[dict]: - """ - Busca um único insumo pelo seu código, retornando seu preço para um - contexto específico (UF, data e regime). - """ + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - SELECT - i.codigo, i.descricao, i.unidade, p.preco_mediano + SELECT i.codigo, i.descricao, i.unidade, p.preco_mediano FROM {settings.TABLE_INSUMOS} AS i JOIN {settings.TABLE_PRECOS_INSUMOS} AS p ON i.codigo = p.insumo_codigo - WHERE i.codigo = :codigo - AND i.status = :status - AND p.uf = :uf - AND TO_CHAR(p.data_referencia, 'YYYY-MM') = :data_referencia + WHERE i.codigo = :codigo AND i.status = :status AND p.uf = :uf + AND p.data_referencia >= :start_date AND p.data_referencia <= :end_date AND p.regime = :regime """) result = db.execute(query, { - "codigo": codigo, "uf": uf.upper(), "data_referencia": data_referencia, + "codigo": codigo, "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper(), "status": settings.DEFAULT_ITEM_STATUS }).first() return result @@ -46,24 +71,18 @@ def get_insumo_by_codigo( def search_insumos_by_descricao( db: Session, q: str, uf: str, data_referencia: str, regime: str, skip: int, limit: int ) -> List[dict]: - """ - Busca insumos por uma string em sua descrição, retornando os preços - para um contexto específico. - """ + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - SELECT - i.codigo, i.descricao, i.unidade, p.preco_mediano + SELECT i.codigo, i.descricao, i.unidade, p.preco_mediano FROM {settings.TABLE_INSUMOS} AS i JOIN {settings.TABLE_PRECOS_INSUMOS} AS p ON i.codigo = p.insumo_codigo - WHERE i.descricao ILIKE :query - AND i.status = :status - AND p.uf = :uf - AND TO_CHAR(p.data_referencia, 'YYYY-MM') = :data_referencia + WHERE i.descricao ILIKE :query AND i.status = :status AND p.uf = :uf + AND p.data_referencia >= :start_date AND p.data_referencia <= :end_date AND p.regime = :regime ORDER BY i.descricao OFFSET :skip LIMIT :limit """) result = db.execute(query, { - "query": f"%{q}%", "uf": uf.upper(), "data_referencia": data_referencia, + "query": f"%{q}%", "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper(), "status": settings.DEFAULT_ITEM_STATUS, "skip": skip, "limit": limit }).fetchall() @@ -72,23 +91,17 @@ def search_insumos_by_descricao( def get_composicao_by_codigo( db: Session, codigo: int, uf: str, data_referencia: str, regime: str ) -> Optional[dict]: - """ - Busca uma única composição pelo seu código, retornando seu custo total - para um contexto específico (UF, data e regime). - """ + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - SELECT - c.codigo, c.descricao, c.unidade, p.custo_total + SELECT c.codigo, c.descricao, c.unidade, p.custo_total FROM {settings.TABLE_COMPOSICOES} AS c JOIN {settings.TABLE_CUSTOS_COMPOSICOES} AS p ON c.codigo = p.composicao_codigo - WHERE c.codigo = :codigo - AND c.status = :status - AND p.uf = :uf - AND TO_CHAR(p.data_referencia, 'YYYY-MM') = :data_referencia + WHERE c.codigo = :codigo AND c.status = :status AND p.uf = :uf + AND p.data_referencia >= :start_date AND p.data_referencia <= :end_date AND p.regime = :regime """) result = db.execute(query, { - "codigo": codigo, "uf": uf.upper(), "data_referencia": data_referencia, + "codigo": codigo, "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper(), "status": settings.DEFAULT_ITEM_STATUS }).first() return result @@ -96,220 +109,102 @@ def get_composicao_by_codigo( def search_composicoes_by_descricao( db: Session, q: str, uf: str, data_referencia: str, regime: str, skip: int, limit: int ) -> List[dict]: - """ - Busca composições por uma string em sua descrição, retornando os custos - para um contexto específico. - """ + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - SELECT - c.codigo, c.descricao, c.unidade, p.custo_total + SELECT c.codigo, c.descricao, c.unidade, p.custo_total FROM {settings.TABLE_COMPOSICOES} AS c JOIN {settings.TABLE_CUSTOS_COMPOSICOES} AS p ON c.codigo = p.composicao_codigo - WHERE c.descricao ILIKE :query - AND c.status = :status - AND p.uf = :uf - AND TO_CHAR(p.data_referencia, 'YYYY-MM') = :data_referencia + WHERE c.descricao ILIKE :query AND c.status = :status AND p.uf = :uf + AND p.data_referencia >= :start_date AND p.data_referencia <= :end_date AND p.regime = :regime ORDER BY c.descricao OFFSET :skip LIMIT :limit """) result = db.execute(query, { - "query": f"%{q}%", "uf": uf.upper(), "data_referencia": data_referencia, + "query": f"%{q}%", "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper(), "status": settings.DEFAULT_ITEM_STATUS, "skip": skip, "limit": limit }).fetchall() return result -# --- Seção 2: Funções de Análise e Business Intelligence (BI) --- +# --- Seção 2: Funções de BI --- +@cache_result(ttl=86400) def get_composicao_bom( db: Session, codigo: int, uf: str, data_referencia: str, regime: str ) -> List[dict]: - """ - Retorna o Bill of Materials (BOM) completo de uma composição, explodindo - todos os níveis de subcomposições e calculando o custo de cada item. - """ - # Esta query recursiva (CTE) navega na árvore de composições. + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - WITH RECURSIVE composicao_completa (composicao_pai_codigo, item_codigo, tipo_item, coeficiente_total, nivel) AS ( - -- Caso base: Itens diretos da composição principal - SELECT - composicao_pai_codigo, - item_codigo, - tipo_item, - coeficiente AS coeficiente_total, - 1 AS nivel - FROM {settings.VIEW_COMPOSICAO_ITENS} + WITH RECURSIVE composicao_completa (item_codigo, tipo_item, coeficiente_total, nivel) AS ( + SELECT item_codigo, tipo_item, coeficiente, 1 FROM {settings.VIEW_COMPOSICAO_ITENS} WHERE composicao_pai_codigo = :codigo - UNION ALL - - -- Passo recursivo: Itens das subcomposições - SELECT - rec.composicao_pai_codigo, - vis.item_codigo, - vis.tipo_item, - rec.coeficiente_total * vis.coeficiente AS coeficiente_total, - rec.nivel + 1 + SELECT vis.item_codigo, vis.tipo_item, rec.coeficiente_total * vis.coeficiente, rec.nivel + 1 FROM {settings.VIEW_COMPOSICAO_ITENS} AS vis JOIN composicao_completa AS rec ON vis.composicao_pai_codigo = rec.item_codigo - WHERE rec.tipo_item = 'COMPOSICAO' + WHERE rec.tipo_item = 'COMPOSICAO' AND rec.nivel < 10 ) - -- Seleção final, unindo com os catálogos e preços/custos - SELECT - cc.item_codigo, - cc.tipo_item, - COALESCE(i.descricao, c.descricao) AS descricao, - COALESCE(i.unidade, c.unidade) AS unidade, - cc.coeficiente_total, - COALESCE(pi.preco_mediano, pc.custo_total) AS custo_unitario, - (cc.coeficiente_total * COALESCE(pi.preco_mediano, pc.custo_total)) AS custo_impacto_total + SELECT cc.item_codigo, cc.tipo_item, MIN(cc.nivel) as nivel, COALESCE(i.descricao, c.descricao) AS descricao, + COALESCE(i.unidade, c.unidade) AS unidade, SUM(cc.coeficiente_total) as coeficiente_total, + COALESCE(pi.preco_mediano, pc.custo_total) AS custo_unitario, + SUM(cc.coeficiente_total * COALESCE(pi.preco_mediano, pc.custo_total)) AS custo_impacto_total FROM composicao_completa cc LEFT JOIN {settings.TABLE_INSUMOS} i ON cc.item_codigo = i.codigo AND cc.tipo_item = 'INSUMO' LEFT JOIN {settings.TABLE_COMPOSICOES} c ON cc.item_codigo = c.codigo AND cc.tipo_item = 'COMPOSICAO' - LEFT JOIN {settings.TABLE_PRECOS_INSUMOS} pi ON cc.item_codigo = pi.insumo_codigo - AND cc.tipo_item = 'INSUMO' - AND pi.uf = :uf AND TO_CHAR(pi.data_referencia, 'YYYY-MM') = :data_referencia AND pi.regime = :regime - LEFT JOIN {settings.TABLE_CUSTOS_COMPOSICOES} pc ON cc.item_codigo = pc.composicao_codigo - AND cc.tipo_item = 'COMPOSICAO' - AND pc.uf = :uf AND TO_CHAR(pc.data_referencia, 'YYYY-MM') = :data_referencia AND pc.regime = :regime - ORDER BY cc.nivel, descricao; - """) - - result = db.execute(query, { - "codigo": codigo, "uf": uf.upper(), "data_referencia": data_referencia, - "regime": regime.upper() - }).fetchall() - return result - -def get_composicao_man_hours( - db: Session, codigo: int -) -> dict: - """ - Calcula o total de Hora/Homem para uma composição, somando os coeficientes - de todos os insumos de mão de obra (unidade 'H') em todos os níveis. - """ - query = text(f""" - WITH RECURSIVE composicao_insumos_base (item_codigo, coeficiente_total) AS ( - SELECT item_codigo, coeficiente AS coeficiente_total - FROM {settings.VIEW_COMPOSICAO_ITENS} - WHERE composicao_pai_codigo = :codigo - - UNION ALL - - SELECT vis.item_codigo, rec.coeficiente_total * vis.coeficiente - FROM {settings.VIEW_COMPOSICAO_ITENS} AS vis - JOIN composicao_insumos_base AS rec ON vis.composicao_pai_codigo = rec.item_codigo - ) - SELECT - SUM(cib.coeficiente_total) AS total_hora_homem - FROM composicao_insumos_base cib - JOIN {settings.TABLE_INSUMOS} i ON cib.item_codigo = i.codigo - WHERE i.unidade = 'H'; + LEFT JOIN {settings.TABLE_PRECOS_INSUMOS} pi ON cc.item_codigo = pi.insumo_codigo AND pi.uf = :uf + AND pi.data_referencia >= :start_date AND pi.data_referencia <= :end_date AND pi.regime = :regime + LEFT JOIN {settings.TABLE_CUSTOS_COMPOSICOES} pc ON cc.item_codigo = pc.composicao_codigo AND pc.uf = :uf + AND pc.data_referencia >= :start_date AND pc.data_referencia <= :end_date AND pc.regime = :regime + GROUP BY 1, 2, 4, 5, 7 ORDER BY nivel, descricao; """) - result = db.execute(query, {"codigo": codigo}).first() - return result + result = db.execute(query, {"codigo": codigo, "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper()}).fetchall() + return [dict(r._mapping) for r in result] +@cache_result(ttl=86400) def get_abc_curve_for_composicoes( - db: Session, codigos: List[int], uf: str, data_referencia: str, regime: str + db: Session, codigos: List[int], uf: str, data_referencia: str, regime: str, top_n: int = 50 ) -> List[dict]: - """ - Calcula a Curva ABC de insumos para um grupo de composições, identificando - os itens de maior impacto financeiro. - """ + start_date, end_date = _get_date_range(data_referencia) query = text(f""" - WITH RECURSIVE composicao_completa (composicao_pai_codigo, item_codigo, tipo_item, coeficiente_total) AS ( - SELECT codigo, codigo, 'COMPOSICAO', 1.0 FROM {settings.TABLE_COMPOSICOES} WHERE codigo IN :codigos + WITH RECURSIVE composicao_completa (item_codigo, tipo_item, coeficiente_total) AS ( + SELECT codigo, 'COMPOSICAO', 1.0 FROM {settings.TABLE_COMPOSICOES} WHERE codigo IN :codigos UNION ALL - SELECT rec.composicao_pai_codigo, vis.item_codigo, vis.tipo_item, rec.coeficiente_total * vis.coeficiente + SELECT vis.item_codigo, vis.tipo_item, rec.coeficiente_total * vis.coeficiente FROM {settings.VIEW_COMPOSICAO_ITENS} as vis JOIN composicao_completa as rec ON vis.composicao_pai_codigo = rec.item_codigo WHERE rec.tipo_item = 'COMPOSICAO' ) - SELECT - i.codigo, - i.descricao, - i.unidade, - SUM(cc.coeficiente_total * p.preco_mediano) AS custo_total_agregado + SELECT i.codigo, i.descricao, i.unidade, SUM(cc.coeficiente_total * p.preco_mediano) AS custo_impacto_total FROM composicao_completa cc JOIN {settings.TABLE_INSUMOS} i ON cc.item_codigo = i.codigo JOIN {settings.TABLE_PRECOS_INSUMOS} p ON i.codigo = p.insumo_codigo - WHERE cc.tipo_item = 'INSUMO' - AND p.uf = :uf - AND TO_CHAR(p.data_referencia, 'YYYY-MM') = :data_referencia - AND p.regime = :regime + WHERE cc.tipo_item = 'INSUMO' AND p.uf = :uf + AND p.data_referencia >= :start_date AND p.data_referencia <= :end_date AND p.regime = :regime GROUP BY i.codigo, i.descricao, i.unidade HAVING SUM(cc.coeficiente_total * p.preco_mediano) > 0 - ORDER BY custo_total_agregado DESC; + ORDER BY custo_impacto_total DESC; """) - params = { - "codigos": tuple(codigos), "uf": uf.upper(), - "data_referencia": data_referencia, "regime": regime.upper() - } - insumos_custo = db.execute(query, params).fetchall() - if not insumos_custo: - return [] - df = pd.DataFrame(insumos_custo, columns=['codigo', 'descricao', 'unidade', 'custo_total_agregado']) - df['custo_total_agregado'] = pd.to_numeric(df['custo_total_agregado']) - custo_total_geral = df['custo_total_agregado'].sum() - df = df.sort_values(by='custo_total_agregado', ascending=False) - df['percentual_individual'] = (df['custo_total_agregado'] / custo_total_geral) * 100 - df['percentual_acumulado'] = df['percentual_individual'].cumsum() - def classificar_abc(percentual_acumulado): - if percentual_acumulado <= 80: - return 'A' - elif percentual_acumulado <= 95: - return 'B' - else: - return 'C' - df['classe_abc'] = df['percentual_acumulado'].apply(classificar_abc) - return df.to_dict(orient='records') - -def get_candidatos_otimizacao( - db: Session, codigo: int, uf: str, data_referencia: str, regime: str, top_n: int = 5 -) -> List[dict]: - """ - Identifica os insumos de maior impacto financeiro em uma composição, - servindo como candidatos para otimização de custos. - """ - bom_completo = get_composicao_bom(db, codigo=codigo, uf=uf, data_referencia=data_referencia, regime=regime) - if not bom_completo: - return [] - - insumos = [item for item in bom_completo if item['tipo_item'] == 'INSUMO' and item['custo_impacto_total'] is not None] - - insumos_sorted = sorted(insumos, key=lambda x: x['custo_impacto_total'], reverse=True) - - return insumos_sorted[:top_n] - + result = db.execute(query, {"codigos": tuple(codigos), "uf": uf.upper(), "start_date": start_date, "end_date": end_date, "regime": regime.upper()}).fetchall() + insumos = [dict(r._mapping) for r in result] + total_geral = sum(float(x['custo_impacto_total'] or 0) for x in insumos) + acumulado = 0.0 + for item in insumos: + impacto = float(item['custo_impacto_total'] or 0) + acumulado += impacto + item['custo_total_agregado'] = impacto + item['percentual_individual'] = (impacto / total_geral * 100) if total_geral > 0 else 0 + item['percentual_acumulado'] = (acumulado / total_geral * 100) if total_geral > 0 else 0 + item['classe_abc'] = 'A' if item['percentual_acumulado'] <= 80 else ('B' if item['percentual_acumulado'] <= 95 else 'C') + return insumos[:top_n] + +@cache_result(ttl=86400) def get_custo_historico( db: Session, tipo_item: str, codigo: int, uf: str, regime: str, data_inicio: str, data_fim: str ) -> List[dict]: - """ - Busca o histórico de preço/custo de um item dentro de um período. - """ - if tipo_item == 'insumo': - table_name = settings.TABLE_PRECOS_INSUMOS - code_column = 'insumo_codigo' - value_column = 'preco_mediano' - elif tipo_item == 'composicao': - table_name = settings.TABLE_CUSTOS_COMPOSICOES - code_column = 'composicao_codigo' - value_column = 'custo_total' - else: - return [] - - query = text(f""" - SELECT TO_CHAR(data_referencia, 'YYYY-MM') as data_referencia, {value_column} as valor - FROM {table_name} - WHERE {code_column} = :codigo - AND uf = :uf - AND regime = :regime - AND TO_CHAR(data_referencia, 'YYYY-MM') >= :data_inicio - AND TO_CHAR(data_referencia, 'YYYY-MM') <= :data_fim - ORDER BY data_referencia ASC - """) - result = db.execute(query, { - "codigo": codigo, "uf": uf.upper(), "regime": regime.upper(), - "data_inicio": data_inicio, "data_fim": data_fim - }).fetchall() - return result \ No newline at end of file + table = settings.TABLE_PRECOS_INSUMOS if tipo_item == 'insumo' else settings.TABLE_CUSTOS_COMPOSICOES + col = 'insumo_codigo' if tipo_item == 'insumo' else 'composicao_codigo' + val = 'preco_mediano' if tipo_item == 'insumo' else 'custo_total' + s_date, _ = _get_date_range(data_inicio) + _, e_date = _get_date_range(data_fim) + query = text(f"SELECT TO_CHAR(data_referencia, 'YYYY-MM') as data_referencia, {val} as valor FROM {table} WHERE {col} = :c AND uf = :uf AND regime = :r AND data_referencia >= :s AND data_referencia <= :e ORDER BY data_referencia") + result = db.execute(query, {"c": codigo, "uf": uf.upper(), "r": regime.upper(), "s": s_date, "e": e_date}).fetchall() + return [dict(r._mapping) for r in result] diff --git a/api/main.py b/api/main.py index 97183da..af25e71 100644 --- a/api/main.py +++ b/api/main.py @@ -8,8 +8,12 @@ """ import os +import redis +from celery.result import AsyncResult +from .sandbox_utils import is_sandbox_mode from typing import List from fastapi import FastAPI, Depends, HTTPException, Query, Body, Path +from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from datetime import date, datetime from dateutil.relativedelta import relativedelta @@ -27,25 +31,89 @@ version="1.0.0", ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Conexão direta com Redis para lock de tarefas (idempotência) +redis_client = redis.Redis(host=os.getenv("REDIS_HOST", "redis"), port=6379, db=0) + +@app.get("/api/v1/public/stats", tags=["Public"]) +def get_database_stats(db: Session = Depends(get_db)): + """ + Retorna estatísticas de volumetria do banco de dados. + """ + return crud.get_global_stats(db) + +@app.get("/api/v1/public/filters", tags=["Public"]) +def get_filters(db: Session = Depends(get_db)): + """ + Retorna os filtros dinâmicos disponíveis no banco. + """ + return crud.get_available_filters(db) + # --- Endpoints de Administração --- -@app.post("/admin/populate-database", status_code=202, tags=["Admin"]) -def trigger_database_population(year: int = Body(..., example=2025), month: int = Body(..., example=9)): +@app.post("/api/v1/admin/populate-database", status_code=202, tags=["Admin"]) +def trigger_database_population( + year: int = Body(..., example=2025), + month: int = Body(..., example=9), + state: str = Body("SP", example="SP", min_length=2, max_length=2) +): """ - Dispara a tarefa de download e população da base SINAPI para um mês/ano. - A tarefa roda em segundo plano (assíncrona). + Dispara a tarefa de download e população da base SINAPI para um mês/ano/UF. + A tarefa roda em segundo plano. Implementa trava (lock) para evitar duplicações. """ + sandbox = is_sandbox_mode() + lock_key = f"lock:autosinapi:populate:{year}:{month:02d}:{state.upper()}:{'sandbox' if sandbox else 'prod'}" + + if not redis_client.set(lock_key, "active", nx=True, ex=3600): + raise HTTPException( + status_code=409, + detail=f"Já existe uma tarefa em andamento para {state.upper()} {month:02d}/{year}." + ) + db_config = { - "host": os.getenv("POSTGRES_HOST", "db"), - "port": os.getenv("POSTGRES_PORT", 5432), - "database": os.getenv("POSTGRES_DB"), - "user": os.getenv("POSTGRES_USER"), - "password": os.getenv("POSTGRES_PASSWORD"), + "host": os.getenv("POSTGRES_NAME", "autosinapi_db"), + "port": 5432, + "database": os.getenv("POSTGRES_DB", "sinapi"), + "user": os.getenv("POSTGRES_USER", "admin"), + "password": os.getenv("POSTGRES_PASSWORD", "admin"), + } + sinapi_config = { + "year": year, + "month": month, + "state": state.upper(), + "type": "REFERENCIA" + } + + try: + task = populate_sinapi_task.delay(db_config, sinapi_config) + redis_client.set(f"task:{lock_key}", task.id, ex=86400) + return { + "message": "Tarefa de população da base de dados iniciada com sucesso.", + "task_id": task.id, + "sandbox": sandbox + } + except Exception as e: + redis_client.delete(lock_key) + raise HTTPException(status_code=500, detail=f"Falha ao enfileirar tarefa: {str(e)}") + +@app.get("/api/v1/admin/tasks/{task_id}", tags=["Admin"]) +def get_task_status(task_id: str): + """Verifica o status e resultado de uma tarefa Celery.""" + result = AsyncResult(task_id, app=populate_sinapi_task.app) + return { + "task_id": task_id, + "status": result.status, + "ready": result.ready(), + "result": str(result.result) if result.ready() else None } - sinapi_config = { "year": year, "month": month } - task = populate_sinapi_task.delay(db_config, sinapi_config) - return {"message": "Tarefa de população da base de dados iniciada com sucesso.", "task_id": task.id} @app.get("/", tags=["Root"]) @@ -55,7 +123,7 @@ def read_root(): # --- Endpoints de Insumos --- -@app.get("/insumos/{codigo}", response_model=schemas.Insumo, tags=["Insumos"]) +@app.get("/api/v1/public/insumos/{codigo}", response_model=schemas.Insumo, tags=["Insumos"]) def read_insumo_by_codigo( codigo: int, uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -71,7 +139,7 @@ def read_insumo_by_codigo( raise HTTPException(status_code=404, detail="Insumo não encontrado para os filtros especificados.") return db_insumo -@app.get("/insumos/", response_model=List[schemas.Insumo], tags=["Insumos"]) +@app.get("/api/v1/public/insumos", response_model=List[schemas.Insumo], tags=["Insumos"]) def search_insumos( q: str = Query(..., min_length=3, description="Termo para buscar na descrição do insumo."), uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -88,7 +156,7 @@ def search_insumos( # --- Endpoints de Composições --- -@app.get("/composicoes/{codigo}", response_model=schemas.Composicao, tags=["Composições"]) +@app.get("/api/v1/public/composicoes/{codigo}", response_model=schemas.Composicao, tags=["Composições"]) def read_composicao_by_codigo( codigo: int, uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -104,7 +172,7 @@ def read_composicao_by_codigo( raise HTTPException(status_code=404, detail="Composição não encontrada para os filtros especificados.") return db_composicao -@app.get("/composicoes/", response_model=List[schemas.Composicao], tags=["Composições"]) +@app.get("/api/v1/public/composicoes", response_model=List[schemas.Composicao], tags=["Composições"]) def search_composicoes( q: str = Query(..., min_length=3, description="Termo para buscar na descrição da composição."), uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -121,7 +189,7 @@ def search_composicoes( # --- Endpoints de Business Intelligence (BI) --- -@app.get("/bi/composicao/{codigo}/bom", response_model=List[schemas.ComposicaoBOMItem], tags=["Business Intelligence"]) +@app.get("/api/v1/public/bi/composicao/{codigo}/bom", response_model=List[schemas.ComposicaoBOMItem], tags=["Business Intelligence"]) def get_composition_bom( codigo: int, uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -138,7 +206,7 @@ def get_composition_bom( raise HTTPException(status_code=404, detail="Composição não encontrada ou sem estrutura para os filtros especificados.") return bom_items -@app.get("/bi/composicao/{codigo}/hora-homem", response_model=schemas.ComposicaoManHours, tags=["Business Intelligence"]) +@app.get("/api/v1/public/bi/composicao/{codigo}/hora-homem", response_model=schemas.ComposicaoManHours, tags=["Business Intelligence"]) def get_composition_man_hours(codigo: int, db: Session = Depends(get_db)): """ Calcula o total de Hora/Homem para uma composição, somando os coeficientes @@ -149,7 +217,7 @@ def get_composition_man_hours(codigo: int, db: Session = Depends(get_db)): return schemas.ComposicaoManHours(total_hora_homem=0.0) return result -@app.post("/bi/curva-abc", response_model=List[schemas.CurvaABCItem], tags=["Business Intelligence"]) +@app.post("/api/v1/public/bi/curva-abc", response_model=List[schemas.CurvaABCItem], tags=["Business Intelligence"]) def get_abc_curve( codigos: List[int] = Body(..., description="Lista de códigos de composições a serem analisadas.", example=[92711, 88307]), uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -166,7 +234,7 @@ def get_abc_curve( raise HTTPException(status_code=404, detail="Nenhum insumo encontrado para as composições e filtros especificados.") return abc_curve -@app.get("/bi/composicao/{codigo}/otimizar", response_model=List[schemas.ComposicaoBOMItem], tags=["Business Intelligence"]) +@app.get("/api/v1/public/bi/composicao/{codigo}/otimizar", response_model=List[schemas.ComposicaoBOMItem], tags=["Business Intelligence"]) def get_optimization_candidates( codigo: int, uf: str = Query(..., description="Unidade Federativa (UF). Ex: SP", min_length=2, max_length=2), @@ -183,7 +251,7 @@ def get_optimization_candidates( raise HTTPException(status_code=404, detail="Não foi possível calcular os candidatos para otimização.") return candidates -@app.get("/bi/item/{tipo_item}/{codigo}/historico", response_model=List[schemas.HistoricoCusto], tags=["Business Intelligence"]) +@app.get("/api/v1/public/bi/item/{tipo_item}/{codigo}/historico", response_model=List[schemas.HistoricoCusto], tags=["Business Intelligence"]) def get_item_cost_history( tipo_item: str = Path(..., description="Tipo do item: 'insumo' ou 'composicao'"), codigo: int = Path(..., description="Código do item."), @@ -213,4 +281,4 @@ def get_item_cost_history( ) if not history: raise HTTPException(status_code=404, detail="Não foram encontrados dados históricos para o item e filtros especificados.") - return history \ No newline at end of file + return history diff --git a/api/sandbox_utils.py b/api/sandbox_utils.py new file mode 100644 index 0000000..d8c6f1d --- /dev/null +++ b/api/sandbox_utils.py @@ -0,0 +1,11 @@ +import os + +def is_sandbox_mode(): + """Verifica se a execução atual deve ocorrer em modo Sandbox.""" + return os.getenv("AUTOSINAPI_SANDBOX", "false").lower() == "true" + +def get_sandbox_table_name(base_name: str): + """Retorna o nome da tabela com sufixo sandbox se estiver no modo sandbox.""" + if is_sandbox_mode(): + return f"{base_name}_sandbox" + return base_name diff --git a/api/tasks.py b/api/tasks.py index d79db3b..12bd0c6 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -16,34 +16,53 @@ aqui, de forma isolada do processo da API. """ +import os +import redis from celery import Celery -# O toolkit AutoSINAPI é importado como uma biblioteca instalada no ambiente. import autosinapi -# Instancia o app Celery, sem configurar URLs diretamente aqui. +# Instancia o app Celery celery_app = Celery('tasks') -# Carrega a configuração a partir do arquivo celery_config.py celery_app.config_from_object('api.celery_config') -@celery_app.task -def populate_sinapi_task(db_config: dict, sinapi_config: dict): +# Cliente Redis para gerenciar o lock +redis_client = redis.Redis(host=os.getenv("REDIS_HOST", "redis"), port=6379, db=0) + +@celery_app.task(bind=True, max_retries=3, default_retry_delay=300) +def populate_sinapi_task(self, db_config: dict, sinapi_config: dict): """ A tarefa que o Celery Worker irá executar para popular a base de dados. - - Recebe as configurações do banco de dados e do SINAPI, e dispara o processo - de ETL da biblioteca `autosinapi`. + Implementa limpeza de lock ao final e política de retentativa. """ + year = sinapi_config.get('year') + month = sinapi_config.get('month') + state = sinapi_config.get('state', 'SP') + mode_suffix = 'sandbox' if os.getenv("AUTOSINAPI_SANDBOX") == "true" else 'prod' + lock_key = f"lock:autosinapi:populate:{year}:{month:02d}:{state.upper()}:{mode_suffix}" + try: - # Chama a interface pública do toolkit para executar o ETL - print(f"Iniciando tarefa de ETL para {sinapi_config.get('month')}/{sinapi_config.get('year')}...") + print(f"[{self.request.id}] Iniciando ETL para {state} {month}/{year} (Modo: {mode_suffix})...") + result = autosinapi.run_etl( db_config=db_config, sinapi_config=sinapi_config, - mode='server' # Modo 'server' é ideal para workers, pois não salva arquivos intermediários + mode='server' ) - print("Tarefa de ETL concluída com sucesso.") + + if result.get("status") == "failed": + msg = result.get("message", "") + print(f"[{self.request.id}] Erro no Toolkit: {msg}") + if "Too Many Requests" in msg or "429" in msg: + raise self.retry(countdown=600) + return result + + print(f"[{self.request.id}] Tarefa de ETL concluída com sucesso.") return result except Exception as e: - # Loga o erro e relança para que a tarefa seja marcada como FALHA no Celery - print(f"Erro ao executar a tarefa de população: {e}") + print(f"[{self.request.id}] Erro fatal ao executar a tarefa: {e}") raise + finally: + # Garante a remoção do lock para permitir novas tentativas + if not self.request.called_directly: + redis_client.delete(lock_key) + print(f"[{self.request.id}] Lock {lock_key} liberado.") diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..465fdc9 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,102 @@ +# AutoSINAPI Demo + +Interface web responsiva que demonstra o potencial da **AutoSINAPI** — API RESTful para dados de custos da construção civil (SINAPI/IBGE). + +## 📁 Estrutura + +``` +demo/ +├── index.html # HTML semântico com acessibilidade (ARIA) +├── style.css # CSS principal (importa css/) +├── tests.js # Suíte de testes unitários + interface + E2E +├── README.md # Este arquivo +│ +├── css/ # CSS modular — 13 arquivos +│ ├── 01-variables.css # Custom properties + dark theme tokens +│ ├── 02-reset.css # Reset + tipografia base +│ ├── 03-navbar.css # Navegação + mobile menu +│ ├── 04-hero.css # Hero section + estatísticas +│ ├── 05-search.css # Search box + filtros + state chips +│ ├── 06-results.css # Grid/List views + skeleton + loader +│ ├── 07-charts.css # Chart containers +│ ├── 08-compare.css # Comparativo stats +│ ├── 09-modal.css # Modal de detalhes +│ ├── 10-footer.css # Footer +│ ├── 11-toast.css # Toast notifications +│ ├── 12-utilities.css # .hidden, .sr-only +│ └── 13-responsive.css # Media queries (320px → 8K) +│ +└── js/ # JavaScript modular — ES Modules + DI + ├── main.js # Entry point: DI wiring + init + ├── config.js # Constantes (API_BASE, timeouts) + ├── state.js # Estado centralizado (factory) + ├── dom.js # Cache DOM + $/$$ helpers (factory) + ├── utils.js # Utilitários + ChartFactory + ViewToggle (factory) + ├── api.js # Camada HTTP + endpoints (factory) + ├── toast.js # Notificações (factory) + ├── theme.js # Tema light/dark + Chart.js sync (factory) + ├── events.js # Inicialização de listeners (factory) + └── modules/ + ├── search.js # Pesquisa & BOM (factory) + ├── abc.js # Curva ABC / BI (factory) + └── compare.js # Comparativo Inter-Regional (factory) +``` + +## 🧪 Testes + +```bash +# Abrir no browser com flag de teste +https://autosinapi.lamp.local/demo/?runTests=true + +# Ou via console do browser +AutoSINAPITests.runAll() +AutoSINAPITests.runUnitTests() +AutoSINAPITests.runInterfaceTests() +AutoSINAPITests.runE2ETests() +AutoSINAPITests.showManualChecklist() +``` + +## 🔌 API Endpoints + +| Endpoint | Método | Descrição | +|----------|--------|-----------| +| `/api/v1/public/stats` | GET | Estatísticas do banco | +| `/api/v1/public/filters` | GET | Filtros dinâmicos (ufs, datas, regimes) | +| `/api/v1/public/insumos` | GET | Busca de insumos | +| `/api/v1/public/insumos/{codigo}` | GET | Insumo específico | +| `/api/v1/public/composicoes` | GET | Busca de composições | +| `/api/v1/public/composicoes/{codigo}` | GET | Composição específica | +| `/api/v1/public/bi/curva-abc` | POST | Curva ABC (body: `[códigos]`) | +| `/api/v1/public/bi/composicao/{codigo}/bom` | GET | Bill of Materials | +| `/api/v1/public/bi/item/{tipo}/{codigo}/historico` | GET | Histórico de custo | + +## 🎨 Tecnologias + +- **HTML5** semântico com ARIA labels + skip link +- **CSS** modular com custom properties, dark mode, 10+ breakpoints (320px→8K) +- **JavaScript** ES Modules com Dependency Injection, zero build step +- **Chart.js** para visualização de dados +- **Suíte de testes** auto-contida (sem dependências externas) + +## 🌐 Compatibilidade + +- Navegadores modernos (ES Modules, CSS custom properties) +- Chrome 61+, Firefox 60+, Safari 11+, Edge 16+ +- Servido via HTTPS (Kong Gateway) +- Responsivo de smartwatch (320px) até 8K + +## 🛠️ Desenvolvimento + +```bash +# Para desenvolver localmente +cd demo/ +python3 -m http.server 8080 +# Abrir http://localhost:8080/?runTests=true +``` + +**Princípios de design:** +- Dependency Injection — cada módulo recebe dependências via factory +- Single Source of Truth — estado centralizado no objeto `state` +- Zero duplicação — ChartFactory e ViewToggle eliminam código repetido +- Fail-safe — optional chaining previne crashes em DOM ausente +- XSS-safe — escapeHtml em todo output de usuário \ No newline at end of file diff --git a/demo/css/01-variables.css b/demo/css/01-variables.css new file mode 100644 index 0000000..7097752 --- /dev/null +++ b/demo/css/01-variables.css @@ -0,0 +1,98 @@ +/* ======================================== + AutoSINAPI Demo - CSS + Mobile-first, Dark Mode, Full Responsive + ======================================== */ + +:root { + --primary: #2563eb; + --primary-dark: #1e40af; + --primary-light: #eff6ff; + --primary-glow: rgba(37, 99, 235, 0.15); + --secondary: #475569; + --secondary-light: #94a3b8; + --bg-main: #f8fafc; + --bg-alt: #f1f5f9; + --bg-card: #ffffff; + --bg-input: #f8fafc; + --bg-hover: #f1f5f9; + --text-main: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --text-inverse: #ffffff; + --border: #e2e8f0; + --success: #10b981; + --success-light: #ecfdf5; + --warning: #f59e0b; + --warning-light: #fffbeb; + --error: #ef4444; + --error-light: #fef2f2; + --tag-insumo-bg: #e0f2fe; + --tag-insumo-text: #0369a1; + --tag-comp-bg: #fef3c7; + --tag-comp-text: #92400e; + --tag-a-bg: #dcfce7; + --tag-a-text: #166534; + --tag-b-bg: #fef9c3; + --tag-b-text: #854d0e; + --tag-c-bg: #fee2e2; + --tag-c-text: #991b1b; + --nav-height: 64px; + --container-max: 1400px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-2xl: 24px; + --radius-full: 9999px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08); + --shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1); + --shadow-2xl: 0 25px 50px -12px rgba(0,0,0,0.25); + --transition-fast: 150ms cubic-bezier(0.4,0,0.2,1); + --transition-slow: 300ms cubic-bezier(0.4,0,0.2,1); + --z-sticky: 200; + --z-dropdown: 300; + --z-overlay: 400; + --z-modal: 500; + --z-toast: 600; + --z-skip: 700; +} + +[data-theme="dark"] { + --primary: #3b82f6; + --primary-dark: #60a5fa; + --primary-light: #1e3a5f; + --primary-glow: rgba(59,130,246,0.25); + --secondary: #94a3b8; + --secondary-light: #64748b; + --bg-main: #0f172a; + --bg-alt: #1e293b; + --bg-card: #1e293b; + --bg-input: #334155; + --bg-hover: #334155; + --text-main: #f1f5f9; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --text-inverse: #0f172a; + --border: #334155; + --success-light: #064e3b; + --warning-light: #451a03; + --error-light: #450a0a; + --tag-insumo-bg: #0c4a6e; + --tag-insumo-text: #7dd3fc; + --tag-comp-bg: #713f12; + --tag-comp-text: #fde68a; + --tag-a-bg: #14532d; + --tag-a-text: #86efac; + --tag-b-bg: #713f12; + --tag-b-text: #fde047; + --tag-c-bg: #7f1d1d; + --tag-c-text: #fca5a5; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: 0 4px 6px rgba(0,0,0,0.4); + --shadow-lg: 0 10px 15px rgba(0,0,0,0.4); + --shadow-xl: 0 20px 25px rgba(0,0,0,0.5); + --shadow-2xl: 0 25px 50px rgba(0,0,0,0.7); +} + diff --git a/demo/css/02-reset.css b/demo/css/02-reset.css new file mode 100644 index 0000000..c8ea5b5 --- /dev/null +++ b/demo/css/02-reset.css @@ -0,0 +1,34 @@ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--nav-height) + 1rem); +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-main); + color: var(--text-main); + line-height: 1.6; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + transition: background var(--transition-slow), color var(--transition-slow); +} + +.skip-link { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + background: var(--primary); + color: white; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + z-index: var(--z-skip); + font-weight: 600; + text-decoration: none; +} +.skip-link:focus { top: 1rem; } + +:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; } + diff --git a/demo/css/03-navbar.css b/demo/css/03-navbar.css new file mode 100644 index 0000000..226ad21 --- /dev/null +++ b/demo/css/03-navbar.css @@ -0,0 +1,182 @@ +/* ========== NAVBAR ========== */ +.navbar { + position: fixed; + top: 0; + width: 100%; + background: rgba(255,255,255,0.85); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + z-index: var(--z-sticky); + transition: background var(--transition-slow), border-color var(--transition-slow); +} +[data-theme="dark"] .navbar { background: rgba(15,23,42,0.9); } + +.nav-container { + max-width: var(--container-max); + margin: 0 auto; + padding: 0 1rem; + height: var(--nav-height); + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.logo h2 { + font-size: clamp(1rem, 2.5vw, 1.3rem); + font-weight: 900; + letter-spacing: -0.03em; + white-space: nowrap; +} +.logo span { color: var(--primary); } + +.badge { + font-size: 0.55rem; + font-weight: 800; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} +.badge-free { + border: 1px solid var(--success); + color: var(--success); + background: var(--success-light); +} + +/* Nav links - horizontal scroll on small screens */ +.nav-links { + display: flex; + gap: 0.1rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-snap-type: x mandatory; + flex-shrink: 1; + min-width: 0; + margin-left: auto; + padding: 0.25rem 0; +} +.nav-links::-webkit-scrollbar { display: none; } + +.nav-links a { + text-decoration: none; + color: var(--secondary); + font-weight: 600; + font-size: clamp(0.7rem, 1.5vw, 0.85rem); + padding: 0.4rem 0.6rem; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + white-space: nowrap; + flex-shrink: 0; + scroll-snap-align: start; +} +.nav-links a:hover, +.nav-links a:focus-visible { + color: var(--primary); + background: var(--primary-light); +} + +.nav-actions { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +.btn-icon { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.45rem; + border-radius: var(--radius-md); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); + flex-shrink: 0; +} +.btn-icon:hover { + background: var(--bg-hover); + color: var(--primary); + border-color: var(--primary); + transform: none; +} + +.icon-sun, .icon-moon { display: none; } +[data-theme="light"] .icon-moon { display: block; } +[data-theme="dark"] .icon-sun { display: block; } + +.icon-menu { display: block; } +.icon-close { display: none; } +body.menu-open .icon-close { display: block; } +body.menu-open .icon-menu { display: none; } + +/* Hamburger hidden by default, shown via media query */ +.btn-hamburger { display: none; } + +/* Hamburger hidden by default, shown via media query */ +.btn-hamburger { display: none; } + +/* Mobile Menu */ +.mobile-menu { + position: absolute; + top: var(--nav-height); + left: 0; + right: 0; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + padding: 1rem; + display: none; + flex-direction: column; + gap: 0.25rem; + box-shadow: var(--shadow-lg); + z-index: var(--z-dropdown); +} +body.menu-open .mobile-menu { + display: flex; + animation: slideDown 0.3s ease-out; +} +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.mobile-menu-link { + display: block; + padding: 0.75rem 1rem; + color: var(--text-secondary); + text-decoration: none; + font-weight: 600; + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} +.mobile-menu-link:hover, +.mobile-menu-link:focus-visible { + background: var(--primary-light); + color: var(--primary); +} + +/* Hide nav-links and show hamburger below 1024px */ +@media (max-width: 1023px) { + .nav-links { display: none; } + .btn-hamburger { display: flex; } +} +@media (min-width: 1024px) { + .mobile-menu { display: none !important; } + .btn-hamburger { display: none; } +} +@media (min-width: 1024px) { + .mobile-menu { display: none !important; } + .btn-hamburger { display: none; } +} + diff --git a/demo/css/04-hero.css b/demo/css/04-hero.css new file mode 100644 index 0000000..fb0db73 --- /dev/null +++ b/demo/css/04-hero.css @@ -0,0 +1,186 @@ +/* ========== HERO ========== */ +.hero { + padding: calc(var(--nav-height) + 3rem) 1.5rem 4rem; + text-align: center; + background: radial-gradient(ellipse at top right, var(--primary-light) 0%, var(--bg-main) 50%); + position: relative; + overflow: hidden; +} +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 50%, var(--primary-glow) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(16,185,129,0.08) 0%, transparent 40%); + pointer-events: none; +} + +.hero-content { + max-width: var(--container-max); + margin: 0 auto; + position: relative; + z-index: 1; +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--bg-card); + padding: 0.5rem 1rem; + border-radius: var(--radius-full); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + border: 1px solid var(--border); + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); +} +.pulse-dot { + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + animation: pulse 2s infinite; + box-shadow: 0 0 8px var(--success); +} +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.2); } +} + +.hero h1 { + font-size: clamp(1.75rem, 5vw, 4rem); + line-height: 1.05; + margin-bottom: 1.25rem; + font-weight: 900; + letter-spacing: -0.04em; +} +.highlight { + color: var(--primary); + position: relative; + display: inline-block; +} +.highlight::after { + content: ''; + position: absolute; + bottom: 0.1em; + left: 0; + right: 0; + height: 0.15em; + background: var(--primary); + opacity: 0.15; + border-radius: var(--radius-full); +} + +.hero-description { + font-size: clamp(0.95rem, 2vw, 1.2rem); + color: var(--secondary); + margin-bottom: 2rem; + max-width: 700px; + margin-left: auto; + margin-right: auto; + line-height: 1.7; +} + +.hero-tech-stack { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-bottom: 3rem; + flex-wrap: wrap; +} +.tech-badge { + background: var(--bg-card); + color: var(--text-secondary); + font-size: 0.7rem; + font-weight: 700; + padding: 0.4rem 0.8rem; + border-radius: var(--radius-full); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + transition: all var(--transition-fast); +} +.tech-badge:hover { + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); +} + +.hero-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + background: var(--bg-card); + padding: 1.5rem; + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-xl); + border: 1px solid var(--border); + margin-bottom: 2.5rem; +} +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0.5rem; + border-radius: var(--radius-lg); + transition: all var(--transition-fast); +} +.stat-card:hover { background: var(--primary-light); } +.stat-icon { font-size: 1.5rem; margin-bottom: 0.5rem; } +.stat-val { + font-size: clamp(1.5rem, 3vw, 2.5rem); + font-weight: 900; + color: var(--primary); + line-height: 1; + margin-bottom: 0.35rem; + font-variant-numeric: tabular-nums; +} +.stat-lbl { + font-size: clamp(0.55rem, 1vw, 0.7rem); + color: var(--text-muted); + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.05em; + text-align: center; + line-height: 1.3; +} + +.hero-examples { margin-top: 2rem; } +.examples-label { + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 600; + margin-bottom: 0.75rem; +} +.examples-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} +.example-btn { + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem 1rem; + border-radius: var(--radius-full); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + gap: 0.4rem; + position: relative; + z-index: auto; +} +.example-btn:hover { + background: var(--primary); + color: white; + border-color: var(--primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + diff --git a/demo/css/05-search.css b/demo/css/05-search.css new file mode 100644 index 0000000..0e4c9d5 --- /dev/null +++ b/demo/css/05-search.css @@ -0,0 +1,88 @@ +/* ========== SEARCH BOX ========== */ +.search-box, .abc-input-box, .compare-input-box { + background: var(--bg-card); + padding: 2.5rem; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + margin-bottom: 3rem; + border: 1px solid var(--border); + transition: background var(--transition-slow), border-color var(--transition-slow); + position: relative; + z-index: 10; +} + +.search-bar-row { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; + align-items: flex-end; + position: relative; + z-index: 20; +} + +.input-wrapper { + flex: 1; + min-width: 250px; + position: relative; + display: flex; + align-items: center; + z-index: 30; +} +.input-icon { + position: absolute; + left: 1.25rem; + color: var(--text-muted); + pointer-events: none; + z-index: 1; +} +.search-bar-row input { + width: 100%; + padding: 1.25rem 1.5rem 1.25rem 3.5rem; + border: 2px solid var(--border); + border-radius: var(--radius-md); + font-size: clamp(1rem, 1.5vw, 1.1rem); + outline: none; + transition: all var(--transition-fast); + background: var(--bg-input); + color: var(--text-main); + font-family: inherit; + pointer-events: auto !important; /* Force clickability */ + position: relative; + z-index: 40; +} +.search-bar-row input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--primary-glow); + background: var(--bg-card); +} + +button, .search-bar-row button { + background: var(--primary); + color: white; + border: none; + padding: 1.25rem 2.5rem; + border-radius: var(--radius-md); + font-weight: 700; + font-size: 1rem; + cursor: pointer; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + z-index: 40; +} +button:hover { + background: var(--primary-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} +button:active { transform: translateY(0); } + +.search-filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} diff --git a/demo/css/06-results.css b/demo/css/06-results.css new file mode 100644 index 0000000..84b1ce7 --- /dev/null +++ b/demo/css/06-results.css @@ -0,0 +1,333 @@ +/* ========== RESULTS ========== */ +.results-actions { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 2rem; + padding: 1rem 1.5rem; + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + transition: background var(--transition-slow), border-color var(--transition-slow); +} +.results-toolbar { + display: flex; + align-items: center; + gap: 1.5rem; + flex: 1; + min-width: 250px; + flex-wrap: wrap; +} +.results-count { + font-weight: 700; + font-size: 0.9rem; + white-space: nowrap; + color: var(--text-main); +} +.toolbar-controls { + display: flex; + gap: 0.75rem; + align-items: center; +} +.sort-select { + padding: 0.4rem 2rem 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 600; + background-color: var(--bg-input); + color: var(--text-main); + cursor: pointer; + font-family: inherit; + transition: all var(--transition-fast); +} +.sort-select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); + outline: none; +} +.view-toggles { + display: flex; + background: var(--bg-alt); + padding: 0.2rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} +.btn-toggle { + background: transparent; + color: var(--text-muted); + padding: 0.35rem 0.6rem; + border-radius: 6px; + cursor: pointer; + border: none; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: auto; + transition: all var(--transition-fast); +} +.btn-toggle.active { + background: var(--bg-card); + color: var(--primary); + box-shadow: var(--shadow-sm); +} +.btn-toggle:hover:not(.active) { + color: var(--text-main); + transform: none; + box-shadow: none; +} +.export-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.btn-export { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem 0.9rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-family: inherit; + position: relative; + z-index: auto; +} +.btn-export:hover { + background: var(--primary); + color: white; + border-color: var(--primary); + transform: none; + box-shadow: none; +} + +/* ========== RESULTS GRID/LIST ========== */ +.results-grid.grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} +.results-grid.list-view { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card { + background: var(--bg-card); + padding: 1.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + cursor: pointer; + transition: all var(--transition-slow); + position: relative; + display: flex; + flex-direction: column; + animation: cardAppear 0.4s ease-out; +} +@keyframes cardAppear { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-xl); + border-color: var(--primary); +} +.card:active { transform: translateY(-1px); } + +.list-view .card { + flex-direction: row; + align-items: center; + gap: 1.5rem; + padding: 1rem 1.5rem; +} +.list-view .card h3 { + flex: 1; + margin-bottom: 0; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.list-view .card .type-tag { + margin-bottom: 0; + min-width: 80px; + text-align: center; +} +.list-view .card .price-row { + border-top: none; + padding-top: 0; + margin-left: auto; +} + +.type-tag { + font-size: 0.6rem; + font-weight: 800; + padding: 0.3rem 0.6rem; + border-radius: 6px; + margin-bottom: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; + display: inline-block; +} +.tag-insumo { background: var(--tag-insumo-bg); color: var(--tag-insumo-text); } +.tag-comp { background: var(--tag-comp-bg); color: var(--tag-comp-text); } +.tag-a { background: var(--tag-a-bg); color: var(--tag-a-text); } +.tag-b { background: var(--tag-b-bg); color: var(--tag-b-text); } +.tag-c { background: var(--tag-c-bg); color: var(--tag-c-text); } + +.card h3 { + font-size: clamp(0.95rem, 1.5vw, 1.1rem); + font-weight: 700; + line-height: 1.4; + margin-bottom: 1.25rem; + color: var(--text-main); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.price-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-top: 1rem; + border-top: 1px solid var(--border); + margin-top: auto; +} +.price-row .val { + font-size: clamp(1.2rem, 2vw, 1.5rem); + font-weight: 900; + color: var(--primary); + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.price-row .unit { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +/* ========== SKELETON LOADER ========== */ +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} +.skeleton-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 1.5rem; + height: 180px; + position: relative; + overflow: hidden; +} +.skeleton-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); + animation: shimmer 1.5s infinite; +} +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.skeleton-abc, .skeleton-compare { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.skeleton-chart { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + height: 350px; + position: relative; + overflow: hidden; +} +.skeleton-chart::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); + animation: shimmer 1.5s infinite; +} +.skeleton-table { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + height: 200px; + position: relative; + overflow: hidden; +} +.skeleton-table::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); + animation: shimmer 1.5s infinite; +} +.skeleton-title { + background: var(--bg-card); + border-radius: var(--radius-md); + border: 1px solid var(--border); + height: 40px; + position: relative; + overflow: hidden; +} +.skeleton-title::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); + animation: shimmer 1.5s infinite; +} + +/* ========== LOADER ========== */ +.loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; + margin: 2rem auto; +} +.loader::before { + content: ''; + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.loader-text { + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 600; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ========== NO RESULTS ========== */ +.no-results { + text-align: center; + padding: 4rem 2rem; + color: var(--text-muted); +} +.no-results svg { margin-bottom: 1.5rem; opacity: 0.4; } +.no-results p { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; } +.no-results-hint { font-size: 0.85rem; opacity: 0.7; } + diff --git a/demo/css/07-charts.css b/demo/css/07-charts.css new file mode 100644 index 0000000..1e09250 --- /dev/null +++ b/demo/css/07-charts.css @@ -0,0 +1,84 @@ +/* ========== CHARTS ========== */ +.chart-container { + position: relative; + height: 350px; + width: 100%; + margin-bottom: 2rem; + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 1.5rem; + overflow: hidden; +} +.chart-container canvas { + width: 100% !important; + height: 100% !important; +} + +/* ========== ABC RESULTS ========== */ +.abc-results, .compare-results { + margin-top: 2rem; + animation: slideUp 0.5s ease-out; +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.abc-table-container { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + overflow: hidden; +} +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} +.table-header h4 { + font-size: 1rem; + font-weight: 700; + color: var(--text-main); +} +.table-badge { + font-size: 0.7rem; + font-weight: 700; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-full); + background: var(--primary-light); + color: var(--primary); +} + +.data-table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.data-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; +} +.data-table th { + background: var(--bg-alt); + padding: 1rem 1.25rem; + text-align: left; + font-size: 0.7rem; + text-transform: uppercase; + font-weight: 800; + color: var(--text-muted); + border-bottom: 2px solid var(--border); + position: sticky; + top: 0; +} +.data-table td { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + font-weight: 500; + color: var(--text-main); +} +.data-table tbody tr { transition: background var(--transition-fast); } +.data-table tbody tr:hover { background: var(--primary-light); } + diff --git a/demo/css/08-compare.css b/demo/css/08-compare.css new file mode 100644 index 0000000..7edf8ae --- /dev/null +++ b/demo/css/08-compare.css @@ -0,0 +1,49 @@ +/* ========== COMPARE STATS ========== */ +.compare-item-name { + text-align: center; + font-size: clamp(1.1rem, 2vw, 1.5rem); + font-weight: 800; + color: var(--text-main); + margin-bottom: 1.5rem; +} +.compare-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} +.compare-stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 0.35rem; + transition: all var(--transition-fast); +} +.compare-stat-card:hover { + border-color: var(--primary); + box-shadow: var(--shadow-md); +} +.compare-stat-label { + font-size: 0.65rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.compare-stat-value { + font-size: clamp(1.1rem, 2vw, 1.5rem); + font-weight: 900; + color: var(--primary); + font-variant-numeric: tabular-nums; +} +.compare-stat-uf { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 600; +} +.compare-chart-container { height: 400px; } + diff --git a/demo/css/09-modal.css b/demo/css/09-modal.css new file mode 100644 index 0000000..00d2e50 --- /dev/null +++ b/demo/css/09-modal.css @@ -0,0 +1,72 @@ +/* ========== MODAL ========== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: var(--z-modal); + display: flex; + justify-content: center; + align-items: center; + padding: 1.5rem; +} + +.modal-container { + background: var(--bg-card); + width: 90%; + max-width: 1400px; + height: 90vh; + border-radius: var(--radius-2xl); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: var(--shadow-2xl); + animation: modalSlideUp 0.3s ease-out; +} + +.modal-header { + padding: 1.5rem 2.5rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-alt); + flex-shrink: 0; +} + +.modal-body { + padding: 2.5rem; + overflow-y: auto; + flex: 1; + -webkit-overflow-scrolling: touch; +} + +.modal-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: 2.5rem; +} + +.modal-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 2rem; + display: flex; + flex-direction: column; +} + +.modal-chart-container { + position: relative; + height: 350px !important; /* Fixed height to prevent infinite growth */ + width: 100%; + flex-shrink: 0; +} + +.modal-description { + font-size: clamp(1.4rem, 2.5vw, 2rem); + font-weight: 800; + color: var(--text-main); + margin: 1rem 0; +} diff --git a/demo/css/10-footer.css b/demo/css/10-footer.css new file mode 100644 index 0000000..280cb2b --- /dev/null +++ b/demo/css/10-footer.css @@ -0,0 +1,118 @@ +/* ========== FOOTER ========== */ +footer { + background: #0f172a; + color: white; + padding: 5rem 2rem; + border-top: 4px solid var(--primary); + margin-top: 6rem; + position: relative; + z-index: 10; +} + +.footer-content { + max-width: var(--container-max); + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 4rem; +} + +.footer-brand { + flex: 2; + min-width: 300px; + text-align: left; +} + +.footer-brand h2 { + font-size: 2rem; + margin-bottom: 1rem; + font-weight: 900; +} +.footer-brand h2 span { color: var(--primary); } + +.footer-brand p { + color: #94a3b8; + font-size: 1rem; + max-width: 400px; + line-height: 1.6; +} + +.footer-links, .footer-meta { + flex: 1; + min-width: 200px; +} + +.footer-links h4, .footer-meta h4 { + font-size: 0.9rem; + font-weight: 800; + margin-bottom: 1.5rem; + color: white; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.footer-link { + display: block; + color: #94a3b8; + text-decoration: none; + font-size: 0.9rem; + padding: 0.5rem 0; + transition: color var(--transition-fast); +} +.footer-link:hover { color: var(--primary); } + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: rgba(16, 185, 129, 0.1); + color: var(--success); + padding: 0.6rem 1.2rem; + border-radius: var(--radius-full); + font-weight: 700; + border: 1px solid rgba(16, 185, 129, 0.2); + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.status-dot { + width: 10px; + height: 10px; + background: var(--success); + border-radius: 50%; + box-shadow: 0 0 12px var(--success); + animation: pulse 2s infinite; +} + +.footer-tier-info { + font-size: 0.85rem; + color: #64748b; + margin-top: 1rem; +} + +.footer-tech-stack { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.footer-tech-badge { + font-size: 0.7rem; + font-weight: 700; + padding: 0.3rem 0.75rem; + border-radius: var(--radius-full); + background: #1e293b; + color: #94a3b8; + border: 1px solid #334155; +} + +@media (max-width: 768px) { + .footer-content { flex-direction: column; align-items: center; text-align: center; } + .footer-brand { text-align: center; } + .footer-brand p { margin-left: auto; margin-right: auto; } + .footer-tech-stack { justify-content: center; } + .footer-meta { display: flex; flex-direction: column; align-items: center; } +} diff --git a/demo/css/11-toast.css b/demo/css/11-toast.css new file mode 100644 index 0000000..875b076 --- /dev/null +++ b/demo/css/11-toast.css @@ -0,0 +1,21 @@ +/* ========== TOAST ========== */ +.toast { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translate(-50%, 150%); + background: var(--text-main); + color: var(--text-inverse); + padding: 0.85rem 2rem; + border-radius: var(--radius-full); + font-weight: 600; + font-size: 0.9rem; + box-shadow: var(--shadow-xl); + transition: transform 0.4s cubic-bezier(0.175,0.885,0.32,1.275); + z-index: var(--z-toast); + pointer-events: none; + max-width: 90vw; + text-align: center; +} +.toast.show { transform: translate(-50%, 0); } + diff --git a/demo/css/12-utilities.css b/demo/css/12-utilities.css new file mode 100644 index 0000000..4c95853 --- /dev/null +++ b/demo/css/12-utilities.css @@ -0,0 +1,3 @@ +/* ========== UTILITIES ========== */ +.hidden { display: none !important; } + diff --git a/demo/css/13-responsive.css b/demo/css/13-responsive.css new file mode 100644 index 0000000..98b6c21 --- /dev/null +++ b/demo/css/13-responsive.css @@ -0,0 +1,170 @@ +/* ========== RESPONSIVE ========== */ + +/* Smartwatch & Very Small (< 375px) */ +@media (max-width: 374px) { + :root { --nav-height: 52px; } + .nav-container { padding: 0 0.5rem; } + .logo h2 { font-size: 0.95rem; } + .badge { display: none; } + .hero { padding: calc(var(--nav-height) + 1.5rem) 0.75rem 2rem; } + .hero h1 { font-size: 1.5rem; } + .hero-description { font-size: 0.85rem; } + .hero-tech-stack { gap: 0.3rem; margin-bottom: 1.5rem; } + .tech-badge { font-size: 0.6rem; padding: 0.3rem 0.5rem; } + .hero-stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 1rem; } + .stat-icon { font-size: 1.2rem; } + .stat-val { font-size: 1.25rem; } + .stat-lbl { font-size: 0.5rem; } + .examples-grid { flex-direction: column; align-items: stretch; } + .example-btn { justify-content: center; font-size: 0.75rem; } + .tool-section { padding: 2rem 0.75rem; } + .section-header { margin-bottom: 1.5rem; } + .section-title { font-size: 1.25rem; } + .section-subtitle { font-size: 0.8rem; } + .api-endpoint { font-size: 0.6rem; padding: 0.4rem 0.6rem; } + .search-box, .abc-input-box, .compare-input-box { padding: 1rem; } + .search-bar-row { flex-direction: column; gap: 0.5rem; } + .input-wrapper { min-width: 100%; } + .search-bar-row button { width: 100%; } + .search-filters { grid-template-columns: 1fr; gap: 0.75rem; } + .state-selector { padding: 0.75rem; } + .state-chips { max-height: 120px; } + .state-chip { font-size: 0.65rem; padding: 0.3rem 0.5rem; } + .results-actions { padding: 0.75rem; flex-direction: column; align-items: stretch; } + .results-toolbar { flex-direction: column; align-items: flex-start; gap: 0.75rem; } + .toolbar-controls { width: 100%; justify-content: space-between; } + .export-group { width: 100%; justify-content: center; } + .results-grid.grid-view { grid-template-columns: 1fr; gap: 0.75rem; } + .card { padding: 1rem; } + .card h3 { font-size: 0.85rem; margin-bottom: 0.75rem; } + .chart-container { height: 200px; padding: 0.75rem; } + .compare-stats { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; } + .compare-stat-card { padding: 0.75rem; } + .compare-chart-container { height: 250px; } + .modal-container { width: 100%; height: 100vh; max-height: 100vh; border-radius: 0; } + .modal-header { padding: 1rem; } + .modal-body { padding: 1rem; } + .modal-grid { grid-template-columns: 1fr; gap: 1rem; } + .modal-card { padding: 1rem; } + .modal-chart-container { height: 180px; } + .footer-content { grid-template-columns: 1fr; gap: 1.5rem; } + .toast { font-size: 0.8rem; padding: 0.7rem 1.25rem; bottom: 1rem; } +} + +/* Small Phones (375px - 479px) */ +@media (min-width: 375px) and (max-width: 479px) { + .hero-stats-grid { grid-template-columns: repeat(2, 1fr); } + .results-grid.grid-view { grid-template-columns: 1fr; } + .compare-stats { grid-template-columns: repeat(2, 1fr); } + .modal-grid { grid-template-columns: 1fr; } +} + +/* Large Phones (480px - 767px) */ +@media (min-width: 480px) and (max-width: 767px) { + .hero-stats-grid { grid-template-columns: repeat(4, 1fr); } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } + .compare-stats { grid-template-columns: repeat(4, 1fr); } + .modal-grid { grid-template-columns: 1fr; } +} + +/* Tablets (768px - 1023px) */ +@media (min-width: 768px) and (max-width: 1023px) { + .hero-stats-grid { grid-template-columns: repeat(4, 1fr); } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } + .modal-container { max-width: 95vw; height: 90vh; } + .modal-grid { grid-template-columns: 1fr; } + .footer-content { grid-template-columns: repeat(2, 1fr); } +} + +/* Laptops (1024px - 1439px) */ +@media (min-width: 1024px) { + .hero-stats-grid { grid-template-columns: repeat(4, 1fr); } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } + .modal-grid { grid-template-columns: repeat(2, 1fr); } + .footer-content { grid-template-columns: 2fr 1fr 1.5fr; } +} + +/* Large Desktops (1440px+) */ +@media (min-width: 1440px) { + :root { --container-max: 1600px; } + html { font-size: 17px; } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } + .chart-container { height: 400px; } + .compare-chart-container { height: 450px; } +} + +/* Full HD (1920px+) */ +@media (min-width: 1920px) { + :root { --container-max: 1800px; } + html { font-size: 18px; } + .hero { padding: calc(var(--nav-height) + 5rem) 2rem 6rem; } + .tool-section { padding: 6rem 2rem; } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); } + .chart-container { height: 450px; } + .compare-chart-container { height: 500px; } + .modal-container { max-width: 1600px; } +} + +/* 4K (2560px+) */ +@media (min-width: 2560px) { + :root { --container-max: 2200px; } + html { font-size: 20px; } + .hero { padding: calc(var(--nav-height) + 6rem) 3rem 8rem; } + .tool-section { padding: 8rem 3rem; } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); } + .chart-container { height: 500px; } + .compare-chart-container { height: 550px; } + .modal-container { max-width: 2000px; } +} + +/* 8K (3840px+) */ +@media (min-width: 3840px) { + :root { --container-max: 3200px; } + html { font-size: 24px; } + .hero { padding: calc(var(--nav-height) + 8rem) 4rem 10rem; } + .tool-section { padding: 10rem 4rem; } + .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); } + .chart-container { height: 600px; } + .compare-chart-container { height: 700px; } + .modal-container { max-width: 2800px; } + .state-chips { max-height: 300px; } +} + +/* Ultra-wide (21:9+) */ +@media (min-aspect-ratio: 21/9) { + .hero-content { max-width: 80%; } + .hero-stats-grid { max-width: 70%; margin-left: auto; margin-right: auto; } + .tool-section { max-width: 80%; } +} + +/* Print */ +@media print { + .navbar, .hero-examples, .api-endpoint, .btn-copy-curl, + .results-actions, .export-group, .view-toggles, + .search-box, .abc-input-box, .compare-input-box, + .state-selector, footer, .toast, .btn-icon, .mobile-menu { display: none !important; } + body { background: white; color: black; } + .hero { padding: 2rem 0; background: none; } + .hero h1 { font-size: 2rem; } + .tool-section { padding: 2rem 0; } + .card { break-inside: avoid; box-shadow: none; border: 1px solid #ccc; } + .modal-overlay { position: static; background: none; backdrop-filter: none; } + .modal-container { width: 100%; height: auto; box-shadow: none; } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* High Contrast */ +@media (prefers-contrast: high) { + :root { --border: #000; --text-muted: #333; } + [data-theme="dark"] { --border: #fff; --text-muted: #ccc; } + .card, button, .btn-export, .btn-toggle { border-width: 2px; } +} diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..c8af84e --- /dev/null +++ b/demo/index.html @@ -0,0 +1,501 @@ + + + + + + + + AutoSINAPI | API de Custos da Construção Civil + + + + + + + + + + + + + + + + +
+ +
+
+
+ + API Online • Free Tier Disponível +
+

A fonte da verdade para custos da construção.

+

Esqueça PDFs e planilhas confusas. A API RESTful da AutoSINAPI entrega dados atualizados, estruturados e prontos para alimentar seus orçamentos e dashboards em milissegundos.

+ +
+ ⚡ FastAPI + 🐘 PostgreSQL (...) + 🚀 Redis Cache + 🛡️ Kong Gateway +
+ +
+
+ + ... + Preços / Custos Consolidados +
+
+ + ... + Composições & BOMs +
+
+ + ... + Insumos Catalogados +
+
+ + < 15ms + Latência Média (Cache Hit) +
+
+ + +
+

Experimente agora:

+
+ + + + + +
+
+
+
+ + +
+
+

Pesquisa Inteligente & Explosão Analítica (BOM)

+

Consulte preços locais ou exploda composições em toda sua hierarquia para encontrar o custo de cada prego e hora de mão de obra.

+
+ GET /api/v1/public/insumos?q={termo}&uf={estado}&data_referencia={data}®ime={regime} + +
+
+ + + + + + + + + +
+ +
+ + +
+
+

Análise de Curva ABC (BI)

+

Descubra quais insumos geram o maior impacto financeiro em um conjunto de composições. Ideal para foco de compras e negociações.

+
+ POST /api/v1/public/bi/curva-abc?uf={estado}&data_referencia={data}®ime={regime} + +
+
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + +
+ + +
+
+

Comparativo Inter-Regional

+

Compare o custo do mesmo item em múltiplos estados simultaneamente para estudos de viabilidade e logística.

+
+ GET /api/v1/public/{tipo}/{codigo}?uf={estado}&data_referencia={data}®ime={regime} + +
+
+ +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ + + +
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+ + + + + + + +
+
+ + + + + + + + + + + + + + + diff --git a/demo/js/api.js b/demo/js/api.js new file mode 100644 index 0000000..f93e875 --- /dev/null +++ b/demo/js/api.js @@ -0,0 +1,77 @@ +/** @file Camada de API — fetch wrapper + endpoints */ +export function createApi(config, toast, utils, state, dom) { + const BASE = config.API_BASE; + + /** Unified fetch with error handling — body consumed ONCE */ + async function request(url, options = {}) { + try { + const response = await fetch(url, options); + const body = await response.json().catch(() => ({ message: `HTTP ${response.status}` })); + if (!response.ok) throw new Error(body.message || `HTTP ${response.status}`); + return body; + } catch (error) { + toast.show(`Erro: ${error.message}`, 'error'); + throw error; + } + } + + return { + request, + + async fetchStats() { + try { + const stats = await request(`${BASE}/stats`); + if (dom.statPrecos) dom.statPrecos.textContent = utils.formatNumber(stats.precos); + if (dom.statComposicoes) dom.statComposicoes.textContent = utils.formatNumber(stats.composicoes); + if (dom.statInsumos) dom.statInsumos.textContent = utils.formatNumber(stats.insumos); + if (dom.heroTotalRecords) dom.heroTotalRecords.textContent = `${utils.formatNumber(stats.precos)} registros`; + } catch (error) { + console.error('Stats fetch failed:', error); + } + }, + + async populateFilters() { + try { + const filters = await request(`${BASE}/filters`); + + state.filters.ufs = filters.ufs || []; + state.filters.dates = filters.datas || []; + state.filters.regimes = filters.regimes || []; + + const populate = (sel, arr) => { + if (!sel) return; + sel.innerHTML = arr.map(v => ``).join(''); + }; + + populate(dom.stateFilter, state.filters.ufs); + populate(dom.dateFilter, state.filters.dates); + populate(dom.regimeFilter, state.filters.regimes); + populate(dom.abcStateFilter, state.filters.ufs); + populate(dom.abcDateFilter, state.filters.dates); + populate(dom.abcRegimeFilter, state.filters.regimes); + populate(dom.compareDateFilter, state.filters.dates); + populate(dom.compareRegimeFilter, state.filters.regimes); + + if (dom.stateFilter) dom.stateFilter.value = utils.getDefaultUf(); + if (dom.dateFilter) dom.dateFilter.value = utils.getDefaultDate(); + if (dom.regimeFilter) dom.regimeFilter.value = utils.getDefaultRegime(); + if (dom.abcStateFilter) dom.abcStateFilter.value = utils.getDefaultUf(); + if (dom.abcDateFilter) dom.abcDateFilter.value = utils.getDefaultDate(); + if (dom.abcRegimeFilter) dom.abcRegimeFilter.value = utils.getDefaultRegime(); + if (dom.compareDateFilter) dom.compareDateFilter.value = utils.getDefaultDate(); + if (dom.compareRegimeFilter) dom.compareRegimeFilter.value = utils.getDefaultRegime(); + + if (dom.stateChips && state.filters.ufs.length > 0) { + dom.stateChips.innerHTML = state.filters.ufs.map(uf => + `` + ).join(''); + } + } catch (error) { + console.error('Filter population failed:', error); + state.filters.ufs = ['SP', 'RJ', 'MG', 'PR', 'SC', 'RS', 'BA', 'PE', 'GO']; + state.filters.dates = [utils.getDefaultDate()]; + state.filters.regimes = ['DESONERADO', 'NAO_DESONERADO', 'SEM_ENCARGOS']; + } + }, + }; +} \ No newline at end of file diff --git a/demo/js/config.js b/demo/js/config.js new file mode 100644 index 0000000..7e8f4f2 --- /dev/null +++ b/demo/js/config.js @@ -0,0 +1,13 @@ +/** @file Configuração central e constantes */ +export const CONFIG = Object.freeze({ + API_BASE: (() => { + const origin = window.location.origin; + return (origin === 'null' || origin.startsWith('file:')) + ? 'https://autosinapi.lamp.local/api/v1/public' + : '/api/v1/public'; + })(), + TOAST_DURATION: 3000, + DEBOUNCE_MS: 300, + FALLBACK_UF: 'SP', + FALLBACK_REGIME: 'NAO_DESONERADO', +}); \ No newline at end of file diff --git a/demo/js/dom.js b/demo/js/dom.js new file mode 100644 index 0000000..758949a --- /dev/null +++ b/demo/js/dom.js @@ -0,0 +1,73 @@ +/** @file Cache DOM — query helpers e cache de elementos */ +export const $ = (sel, ctx = document) => ctx.querySelector(sel); +export const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; + +export function createDom() { + return { + themeToggle: $('#themeToggle'), + mobileMenuBtn: $('#mobileMenuBtn'), + mobileMenu: $('#mobileMenu'), + // Hero + exampleBtns: $$('.example-btn'), + heroTotalRecords: $('#hero-total-records'), + statPrecos: $('#stat-precos'), + statComposicoes: $('#stat-composicoes'), + statInsumos: $('#stat-insumos'), + // Search + searchInput: $('#searchInput'), + stateFilter: $('#stateFilter'), + dateFilter: $('#dateFilter'), + regimeFilter: $('#regimeFilter'), + searchBtn: $('#searchBtn'), + resultsGrid: $('#resultsGrid'), + resultsActions: $('#resultsActions'), + resultsCount: $('#resultsCount'), + sortSelect: $('#sortSelect'), + btnGrid: $('#btnGrid'), + btnList: $('#btnList'), + searchSkeleton: $('#searchSkeleton'), + noResults: $('#noResults'), + loader: $('#loader'), + // ABC + abcInput: $('#abcInput'), + abcStateFilter: $('#abcStateFilter'), + abcDateFilter: $('#abcDateFilter'), + abcRegimeFilter: $('#abcRegimeFilter'), + abcBtn: $('#abcBtn'), + abcSkeleton: $('#abcSkeleton'), + abcResults: $('#abcResults'), + abcResultsActions: $('#abcResultsActions'), + abcResultsCount: $('#abcResultsCount'), + btnAbcGrid: $('#btnAbcGrid'), + btnAbcList: $('#btnAbcList'), + abcChart: $('#abcChart'), + abcGrid: $('#abcGrid'), + abcTable: $('#abcTable'), + abcTableWrapper: $('#abcTableWrapper'), + // Compare + compareType: $('#compareType'), + compareCode: $('#compareCode'), + compareDateFilter: $('#compareDateFilter'), + compareRegimeFilter: $('#compareRegimeFilter'), + compareBtn: $('#compareBtn'), + stateChips: $('#stateChips'), + selectAllStates: $('#selectAllStates'), + clearAllStates: $('#clearAllStates'), + presetRegions: $('#presetRegions'), + selectedStatesCount: $('#selectedStatesCount'), + compareSkeleton: $('#compareSkeleton'), + compareResults: $('#compareResults'), + compareChart: $('#compareChart'), + compareItemName: $('#compareItemName'), + compareStats: $('#compareStats'), + compareMin: $('#compareMin'), + compareMax: $('#compareMax'), + compareAvg: $('#compareAvg'), + compareVariation: $('#compareVariation'), + // Modal + detailModal: $('#detailModal'), + historyChart: $('#historyChart'), + // Toast + toast: $('#toast'), + }; +} \ No newline at end of file diff --git a/demo/js/events.js b/demo/js/events.js new file mode 100644 index 0000000..4e92b35 --- /dev/null +++ b/demo/js/events.js @@ -0,0 +1,118 @@ +/** @file Inicialização de event listeners — wiring entre DOM e módulos */ +import { $, $$ } from './dom.js'; + +export function createEvents(dom, { search, abc, compare, theme, toast, state, utils, modal }) { + const handlers = { + theme() { + dom.themeToggle?.addEventListener('click', () => theme.toggle()); + }, + + mobileMenu() { + dom.mobileMenuBtn?.addEventListener('click', () => { + const isOpen = document.body.classList.toggle('menu-open'); + dom.mobileMenu?.classList.toggle('hidden', !isOpen); + dom.mobileMenuBtn?.classList.toggle('active'); + dom.mobileMenuBtn?.setAttribute('aria-expanded', isOpen); + + const iconMenu = dom.mobileMenuBtn.querySelector('.icon-menu'); + const iconClose = dom.mobileMenuBtn.querySelector('.icon-close'); + if (iconMenu) iconMenu.style.display = isOpen ? 'none' : ''; + if (iconClose) iconClose.style.display = isOpen ? '' : 'none'; + }); + + dom.mobileMenu?.querySelectorAll('a').forEach(link => { + link.addEventListener('click', () => { + document.body.classList.remove('menu-open'); + dom.mobileMenu?.classList.add('hidden'); + dom.mobileMenuBtn?.classList.remove('active'); + dom.mobileMenuBtn?.setAttribute('aria-expanded', false); + const iconMenu = dom.mobileMenuBtn?.querySelector('.icon-menu'); + const iconClose = dom.mobileMenuBtn?.querySelector('.icon-close'); + if (iconMenu) iconMenu.style.display = ''; + if (iconClose) iconClose.style.display = 'none'; + }); + }); + }, + + heroExamples() { + dom.exampleBtns.forEach(btn => { + btn.addEventListener('click', () => { + const query = btn.dataset.search; + if (query && dom.searchInput) { + dom.searchInput.value = query; + btn.dataset.section && $(`#${btn.dataset.section}`)?.scrollIntoView({ behavior: 'smooth' }); + search.perform(); + } + }); + }); + }, + + search() { + dom.searchBtn?.addEventListener('click', () => search.perform()); + dom.searchInput?.addEventListener('keypress', e => { if (e.key === 'Enter') search.perform(); }); + dom.sortSelect?.addEventListener('change', e => { state.search.sortBy = e.target.value; search.render(); }); + dom.btnGrid?.addEventListener('click', () => search.setView('grid')); + dom.btnList?.addEventListener('click', () => search.setView('list')); + }, + + abc() { + dom.abcBtn?.addEventListener('click', () => abc.perform()); + dom.btnAbcGrid?.addEventListener('click', () => abc.setView('grid')); + dom.btnAbcList?.addEventListener('click', () => abc.setView('table')); + }, + + compare() { + dom.compareBtn?.addEventListener('click', () => compare.perform()); + dom.stateChips?.addEventListener('click', e => { + const chip = e.target.closest('.state-chip'); + chip && compare.toggleState(chip.dataset.uf); + }); + dom.selectAllStates?.addEventListener('click', () => compare.selectAll()); + dom.clearAllStates?.addEventListener('click', () => compare.clearAll()); + dom.presetRegions?.addEventListener('click', () => compare.presetRegions()); + }, + + copyCurl() { + $$('[data-curl]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const curl = e.currentTarget.dataset.curl; + if (curl && await utils.copyToClipboard(curl)) { + toast.show('cURL copiado!', 'success'); + } + }); + }); + }, + + modal() { + $('.modal-close', dom.detailModal)?.addEventListener('click', () => modal.close()); + dom.detailModal?.addEventListener('click', e => { + if (e.target === dom.detailModal) modal.close(); + }); + }, + + cardClicks() { + // Event delegation para abrir o modal ao clicar em qualquer card (Search ou ABC) + dom.resultsGrid?.addEventListener('click', (e) => { + const card = e.target.closest('.card'); + if (card) { + const { codigo, tipo } = card.dataset; + modal.show(tipo, codigo); + } + }); + + dom.abcGrid?.addEventListener('click', (e) => { + const card = e.target.closest('.card'); + if (card) { + const { codigo, tipo } = card.dataset; + modal.show(tipo, codigo); + } + }); + }, + }; + + return { + init() { + Object.values(handlers).forEach(h => h()); + }, + }; +} \ No newline at end of file diff --git a/demo/js/main.js b/demo/js/main.js new file mode 100644 index 0000000..51c7106 --- /dev/null +++ b/demo/js/main.js @@ -0,0 +1,52 @@ +/** + * AutoSINAPI Demo — Entry Point + * Arquitetura modular com Dependency Injection + * @version 3.0.0 + */ +import { CONFIG } from './config.js'; +import { createState } from './state.js'; +import { createDom } from './dom.js'; +import { createUtils } from './utils.js'; +import { createToast } from './toast.js'; +import { createTheme } from './theme.js'; +import { createApi } from './api.js'; +import { createSearch } from './modules/search.js'; +import { createABC } from './modules/abc.js'; +import { createCompare } from './modules/compare.js'; +import { createModal } from './modules/modal.js'; +import { createEvents } from './events.js'; + +// ── Injeção de Dependências ────────────────── +const state = createState(); +const dom = createDom(); +const utils = createUtils(state); +const toast = createToast(dom, CONFIG); +const theme = createTheme(state); +const api = createApi(CONFIG, toast, utils, state, dom); +const search = createSearch(CONFIG, state, dom, utils, api, toast); +const abc = createABC(CONFIG, state, dom, utils, api, toast, theme); +const compare = createCompare(CONFIG, state, dom, utils, api, toast); +const modal = createModal(CONFIG, state, dom, utils, api, toast); +const events = createEvents(dom, { search, abc, compare, theme, toast, state, utils, modal }); + +// ── Inicialização ──────────────────────────── +async function init() { + theme.init(); + events.init(); + await Promise.all([api.populateFilters(), api.fetchStats()]); +} + +(document.readyState === 'loading') + ? document.addEventListener('DOMContentLoaded', init) + : init(); + +// ── Globais para handlers inline (HTML) ────── +window.exportSearch = (fmt) => search.export(fmt); +window.exportBOM = (fmt) => toast.show(`Export BOM ${fmt} em desenvolvimento`, 'info'); +window.closeModal = () => modal.close(); + +// ── Testabilidade (dev apenas) ─────────────── +if (['localhost', '127.0.0.1'].includes(window.location.hostname) || window.location.hostname.includes('lamp.local')) { + window.AutoSINAPI = { state, search, abc, compare, theme, api, toast, utils, modal }; + console.log('[AutoSINAPI] Test interface: window.AutoSINAPI'); +} \ No newline at end of file diff --git a/demo/js/modules/abc.js b/demo/js/modules/abc.js new file mode 100644 index 0000000..9a9d839 --- /dev/null +++ b/demo/js/modules/abc.js @@ -0,0 +1,131 @@ +/** @file Módulo de Curva ABC (BI) */ +import { createChartConfig, createViewToggle } from '../utils.js'; + +export function createABC(config, state, dom, utils, api, toast) { + const viewToggle = createViewToggle( + 'display', + { grid: dom.abcGrid, tableWrapper: dom.abcTableWrapper, btnGrid: dom.btnAbcGrid, btnList: dom.btnAbcList }, + state.abc + ); + + function cleanupChart() { + state.abc.chart?.destroy(); + state.abc.chart = null; + } + + async function perform() { + const codes = dom.abcInput?.value?.trim(); + if (!codes) { toast.show('Digite pelo menos um código', 'warning'); return; } + + state.abc.loading = true; + dom.abcSkeleton?.classList.remove('hidden'); + dom.abcResults?.classList.add('hidden'); + dom.abcResultsActions?.classList.add('hidden'); + cleanupChart(); + + try { + const uf = dom.abcStateFilter?.value || utils.getDefaultUf(); + const date = dom.abcDateFilter?.value || utils.getDefaultDate(); + const regime = dom.abcRegimeFilter?.value || utils.getDefaultRegime(); + const codeList = codes.split(',').map(c => parseInt(c.trim(), 10)).filter(n => !isNaN(n)); + + const url = `${config.API_BASE}/bi/curva-abc?uf=${uf}&data_referencia=${date}®ime=${encodeURIComponent(regime)}`; + + const data = await api.request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(codeList), + }); + + state.abc.data = Array.isArray(data) ? data : (data.data || []); + render(); + } catch (error) { + toast.show(`Erro ABC: ${error.message}`, 'error'); + } finally { + state.abc.loading = false; + setTimeout(() => dom.abcSkeleton?.classList.add('hidden'), 300); + } + } + + function render() { + const data = state.abc.data; + if (!data?.length) return; + + dom.abcResults?.classList.remove('hidden'); + dom.abcResultsActions?.classList.remove('hidden'); + if (dom.abcResultsCount) dom.abcResultsCount.textContent = `${data.length} insumo(s) analisado(s)`; + + // Grid view + if (dom.abcGrid) { + dom.abcGrid.innerHTML = data.map(item => ` +
+ Classe ${item.classe_abc} +

${utils.escapeHtml(item.descricao)}

+
+ ${utils.formatCurrency(item.custo_total_agregado)} + ${(item.percentual_acumulado || 0).toFixed(1)}% acum. +
+
+ `).join(''); + } + + // Table view + const tbody = dom.abcTable?.querySelector('tbody'); + if (tbody) { + tbody.innerHTML = data.map(item => ` + + ${item.classe_abc} + ${item.codigo} + ${utils.escapeHtml(item.descricao)} + ${item.unidade} + ${utils.formatCurrency(item.custo_total_agregado)} + ${(item.percentual_acumulado || 0).toFixed(1)}% + + `).join(''); + } + + // Chart + if (dom.abcChart) { + const labels = data.slice(0, 15).map(i => i.descricao.substring(0, 20) + '...'); + const impacts = data.slice(0, 15).map(i => i.custo_total_agregado); + const accumulated = data.slice(0, 15).map(i => i.percentual_acumulado); + + const chartConfig = { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Impacto Financeiro (R$)', + data: impacts, + backgroundColor: '#2563eb', + yAxisID: 'y', + }, + { + label: '% Acumulado', + data: accumulated, + type: 'line', + borderColor: '#ef4444', + borderWidth: 2, + pointRadius: 3, + yAxisID: 'y1', + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { type: 'linear', display: true, position: 'left', beginAtZero: true }, + y1: { type: 'linear', display: true, position: 'right', min: 0, max: 100, grid: { drawOnChartArea: false } } + } + } + }; + state.abc.chart = new Chart(dom.abcChart.getContext('2d'), chartConfig); + } + + viewToggle.setView(state.abc.viewMode || 'grid'); + } + + return { perform, render, setView: viewToggle.setView }; +} diff --git a/demo/js/modules/compare.js b/demo/js/modules/compare.js new file mode 100644 index 0000000..9192965 --- /dev/null +++ b/demo/js/modules/compare.js @@ -0,0 +1,111 @@ +/** @file Módulo de Comparativo Inter-Regional */ +import { createChartConfig } from '../utils.js'; + +export function createCompare(config, state, dom, utils, api, toast) { + function cleanupChart() { + state.compare.chart?.destroy(); + state.compare.chart = null; + } + + async function perform() { + const code = dom.compareCode?.value?.trim(); + if (!code) { toast.show('Digite um código SINAPI', 'warning'); return; } + + const type = dom.compareType?.value || 'insumos'; + const date = dom.compareDateFilter?.value || utils.getDefaultDate(); + const regime = dom.compareRegimeFilter?.value || utils.getDefaultRegime(); + + // Dinamismo: Usar TODAS as UFs disponíveis no banco (carregadas via API) + const availableUfs = state.filters.ufs.length > 0 ? state.filters.ufs : ['SP', 'RJ', 'MG', 'AC', 'BA', 'PR', 'AM', 'CE']; + + state.compare.loading = true; + dom.compareSkeleton?.classList.remove('hidden'); + dom.compareResults?.classList.add('hidden'); + cleanupChart(); + + try { + const promises = availableUfs.map(uf => + api.request(`${config.API_BASE}/${type}/${encodeURIComponent(code)}?uf=${uf}&data_referencia=${date}®ime=${encodeURIComponent(regime)}`) + .then(data => ({ uf, data, success: true })) + .catch(() => ({ uf, data: null, success: false })) + ); + + const results = await Promise.all(promises); + const validData = results.filter(r => r.success && r.data); + + if (validData.length === 0) { + throw new Error('Item não encontrado em nenhum estado para esta referência.'); + } + + state.compare.data = validData.map(r => ({ + uf: r.uf, + descricao: r.data.descricao, + valor: parseFloat(r.data.preco_mediano || r.data.custo_total || 0) + })); + + render(); + } catch (error) { + toast.show(error.message, 'error'); + } finally { + state.compare.loading = false; + setTimeout(() => dom.compareSkeleton?.classList.add('hidden'), 300); + } + } + + function render() { + const data = state.compare.data; + if (!data?.length) return; + + dom.compareResults?.classList.remove('hidden'); + if (dom.compareItemName) dom.compareItemName.textContent = data[0].descricao; + + const values = data.map(d => d.valor).filter(v => v > 0); + if (values.length > 0 && dom.compareStats) { + dom.compareStats.classList.remove('hidden'); + const min = Math.min(...values); + const max = Math.max(...values); + const avg = values.reduce((a, b) => a + b, 0) / values.length; + + if (dom.compareMin) { + const minItem = data.find(d => d.valor === min); + dom.compareMin.textContent = utils.formatCurrency(min); + if (dom.compareMinUf) dom.compareMinUf.textContent = minItem.uf; + } + if (dom.compareMax) { + const maxItem = data.find(d => d.valor === max); + dom.compareMax.textContent = utils.formatCurrency(max); + if (dom.compareMaxUf) dom.compareMaxUf.textContent = maxItem.uf; + } + if (dom.compareAvg) dom.compareAvg.textContent = utils.formatCurrency(avg); + if (dom.compareVariation) dom.compareVariation.textContent = `${((max - min) / min * 100).toFixed(1)}%`; + } + + if (dom.compareChart) { + const chartLabels = data.map(d => d.uf); + const chartValues = data.map(d => d.valor); + + const ctx = dom.compareChart.getContext('2d'); + const configChart = { + type: 'bar', + data: { + labels: chartLabels, + datasets: [{ + label: 'Valor (R$)', + data: chartValues, + backgroundColor: chartValues.map((_, i) => `hsl(${220 + (i * 10)}, 70%, 60%)`), + borderRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { y: { beginAtZero: true } } + } + }; + state.compare.chart = new Chart(ctx, configChart); + } + } + + return { perform, render }; +} diff --git a/demo/js/modules/modal.js b/demo/js/modules/modal.js new file mode 100644 index 0000000..5a33011 --- /dev/null +++ b/demo/js/modules/modal.js @@ -0,0 +1,137 @@ +/** @file Módulo do Modal de Detalhes (Histórico + BOM) */ +import { createChartConfig } from '../utils.js'; + +export function createModal(config, state, dom, utils, api, toast) { + let historyChartInstance = null; + + function cleanup() { + if (historyChartInstance) { + historyChartInstance.destroy(); + historyChartInstance = null; + } + } + + async function show(tipo, codigo) { + if (!codigo) return; + + const tipoPlural = tipo === 'insumo' ? 'insumos' : 'composicoes'; + const tipoSingular = tipo === 'insumo' ? 'insumo' : 'composicao'; + + dom.detailModal?.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + cleanup(); + + if (dom.bomTableContainer) dom.bomTableContainer.innerHTML = '
'; + if (dom.modalTitle) dom.modalTitle.textContent = 'Carregando detalhes...'; + if (dom.modalDesc) dom.modalDesc.textContent = ''; + if (dom.modalCode) dom.modalCode.textContent = codigo; + if (dom.modalUn) dom.modalUn.textContent = '...'; + + if (dom.bomSection) { + dom.bomSection.classList.toggle('hidden', tipoSingular === 'insumo'); + } + + try { + const uf = dom.stateFilter?.value || utils.getDefaultUf(); + const date = dom.dateFilter?.value || utils.getDefaultDate(); + const regime = dom.regimeFilter?.value || utils.getDefaultRegime(); + + const itemUrl = `${config.API_BASE}/${tipoPlural}/${encodeURIComponent(codigo)}?uf=${uf}&data_referencia=${date}®ime=${encodeURIComponent(regime)}`; + const itemData = await api.request(itemUrl); + + if (dom.modalTitle) dom.modalTitle.textContent = tipoSingular === 'insumo' ? 'Detalhes do Insumo' : 'Detalhes da Composição'; + if (dom.modalDesc) dom.modalDesc.textContent = itemData.descricao || 'Sem descrição'; + if (dom.modalUn) dom.modalUn.textContent = itemData.unidade || 'N/A'; + if (dom.modalTypeBadge) { + dom.modalTypeBadge.textContent = tipoSingular.toUpperCase(); + dom.modalTypeBadge.className = `type-tag tag-${tipoSingular === 'insumo' ? 'insumo' : 'comp'}`; + } + + const historyUrl = `${config.API_BASE}/bi/item/${tipoSingular}/${encodeURIComponent(codigo)}/historico?uf=${uf}®ime=${encodeURIComponent(regime)}&meses=12`; + const historyData = await api.request(historyUrl).catch(() => []); + + if (dom.historyChart && historyData.length > 0) { + const ctx = dom.historyChart.getContext('2d'); + const configChart = createChartConfig('line', + historyData.map(h => h.data_referencia), + [{ + label: 'Preço Histórico', + data: historyData.map(h => h.valor), + borderColor: state.theme === 'dark' ? '#60a5fa' : '#3b82f6', + backgroundColor: state.theme === 'dark' ? 'rgba(96,165,250,0.1)' : 'rgba(59,130,246,0.1)', + fill: true, + tension: 0.4, + }], + state.theme, + { scales: { y: { beginAtZero: false } } } + ); + historyChartInstance = new Chart(ctx, configChart); + } + + if (tipoSingular === 'composicao' && dom.bomTableContainer) { + const bomUrl = `${config.API_BASE}/bi/composicao/${encodeURIComponent(codigo)}/bom?uf=${uf}&data_referencia=${date}®ime=${encodeURIComponent(regime)}`; + const bomData = await api.request(bomUrl).catch(() => []); + + if (bomData.length === 0) { + dom.bomTableContainer.innerHTML = '

Sem dados de Bill of Materials disponíveis.

'; + } else { + // Consolidação de itens duplicados no frontend para garantir visão Flat + const consolidated = bomData.reduce((acc, curr) => { + const key = `${curr.item_codigo}-${curr.tipo_item}`; + if (!acc[key]) { + acc[key] = { ...curr }; + } else { + acc[key].coeficiente_total = (acc[key].coeficiente_total || 0) + (curr.coeficiente_total || 0); + acc[key].custo_impacto_total = (acc[key].custo_impacto_total || 0) + (curr.custo_impacto_total || 0); + acc[key].nivel = Math.min(acc[key].nivel, curr.nivel); + } + return acc; + }, {}); + + const rows = Object.values(consolidated).sort((a, b) => a.nivel - b.nivel); + + dom.bomTableContainer.innerHTML = ` + + + + + + + + + + + + + ${rows.map(bom => ` + + + + + + + + + `).join('')} + +
NívelCódigoDescriçãoUnidadeQtd. CoefTotal
${bom.nivel}${bom.item_codigo} + ${bom.tipo_item}
+ ${utils.escapeHtml(bom.descricao || 'N/A')} +
${utils.escapeHtml(bom.unidade || 'N/A')}${(bom.coeficiente_total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 4 })}${utils.formatCurrency(bom.custo_impacto_total || 0)}
+ `; + } + } + } catch (error) { + toast.show('Erro ao carregar detalhes', 'error'); + close(); + } + } + + function close() { + dom.detailModal?.classList.add('hidden'); + document.body.style.overflow = 'auto'; + cleanup(); + } + + return { show, close }; +} diff --git a/demo/js/modules/search.js b/demo/js/modules/search.js new file mode 100644 index 0000000..57ced23 --- /dev/null +++ b/demo/js/modules/search.js @@ -0,0 +1,94 @@ +/** @file Módulo de Pesquisa / BOM */ +import { createViewToggle } from '../utils.js'; + +export function createSearch(config, state, dom, utils, api, toast) { + const viewToggle = createViewToggle( + 'class', + { container: dom.resultsGrid, baseClass: 'results-grid', btnGrid: dom.btnGrid, btnList: dom.btnList }, + state.search + ); + + async function perform() { + const query = dom.searchInput?.value?.trim(); + if (!query || query.length < 3) { + toast.show('Digite pelo menos 3 caracteres', 'warning'); + return; + } + + state.search.loading = true; + dom.searchSkeleton?.classList.remove('hidden'); + if (dom.resultsGrid) dom.resultsGrid.innerHTML = ''; + dom.noResults?.classList.add('hidden'); + + try { + const uf = dom.stateFilter?.value || utils.getDefaultUf(); + const date = dom.dateFilter?.value || utils.getDefaultDate(); + const regime = dom.regimeFilter?.value || utils.getDefaultRegime(); + const url = `${config.API_BASE}/insumos?q=${encodeURIComponent(query)}&uf=${uf}&data_referencia=${date}®ime=${encodeURIComponent(regime)}`; + + const data = await api.request(url); + state.search.results = Array.isArray(data) ? data : (data.data || []); + render(); + } catch { + dom.resultsGrid.innerHTML = '

Erro ao buscar resultados.

'; + } finally { + state.search.loading = false; + setTimeout(() => dom.searchSkeleton?.classList.add('hidden'), 300); + } + } + + function render() { + const results = state.search.results; + if (!results?.length) { + dom.resultsGrid.innerHTML = ''; + dom.noResults?.classList.remove('hidden'); + dom.resultsActions?.classList.add('hidden'); + return; + } + + dom.resultsActions?.classList.remove('hidden'); + if (dom.resultsCount) dom.resultsCount.textContent = `${results.length} resultado(s)`; + + const sorted = [...results].sort((a, b) => { + switch (state.search.sortBy) { + case 'name_asc': return (a.descricao || '').localeCompare(b.descricao || ''); + case 'name_desc': return (b.descricao || '').localeCompare(a.descricao || ''); + case 'price_desc': return (b.preco_mediano || 0) - (a.preco_mediano || 0); + case 'price_asc': return (a.preco_mediano || 0) - (b.preco_mediano || 0); + default: return 0; + } + }); + + dom.resultsGrid.innerHTML = sorted.map(item => ` +
+ INSUMO +

${utils.escapeHtml(item.descricao || 'Sem descrição')}

+
+ ${utils.formatCurrency(item.preco_mediano || 0)} + ${utils.escapeHtml(item.unidade || 'N/A')} +
+
+ `).join(''); + } + + function exportData(format) { + const data = state.search.results; + if (!data?.length) { toast.show('Nada para exportar', 'warning'); return; } + + const content = format === 'json' + ? JSON.stringify(data, null, 2) + : [Object.keys(data[0]).join(','), ...data.map(r => + Object.values(r).map(v => `"${(v || '').toString().replace(/"/g, '""')}"`).join(',') + )].join('\n'); + + const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sinapi-pesquisa.${format === 'json' ? 'json' : 'csv'}`; + a.click(); + URL.revokeObjectURL(url); + } + + return { perform, render, setView: viewToggle.setView, export: exportData }; +} \ No newline at end of file diff --git a/demo/js/state.js b/demo/js/state.js new file mode 100644 index 0000000..7305b86 --- /dev/null +++ b/demo/js/state.js @@ -0,0 +1,11 @@ +/** @file Estado centralizado da aplicação */ +export function createState() { + return { + theme: localStorage.getItem('autosinapi-theme') || + (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'), + filters: { ufs: [], dates: [], regimes: [] }, + search: { results: [], loading: false, sortBy: 'name_asc', viewMode: 'grid' }, + abc: { data: null, loading: false, viewMode: 'grid', chart: null }, + compare: { data: null, loading: false, chart: null, selectedStates: new Set() }, + }; +} \ No newline at end of file diff --git a/demo/js/theme.js b/demo/js/theme.js new file mode 100644 index 0000000..4047ed8 --- /dev/null +++ b/demo/js/theme.js @@ -0,0 +1,39 @@ +/** @file Tema — light/dark toggle + Chart.js sync */ +export function createTheme(state) { + function updateChart(chart) { + const isDark = state.theme === 'dark'; + const textColor = isDark ? '#f0f0f0' : '#1a1a1a'; + const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; + + const { scales, plugins } = chart.options; + if (scales?.x) { + scales.x.ticks && (scales.x.ticks.color = textColor); + scales.x.grid && (scales.x.grid.color = gridColor); + } + if (scales?.y) { + scales.y.ticks && (scales.y.ticks.color = textColor); + scales.y.grid && (scales.y.grid.color = gridColor); + } + if (plugins?.legend?.labels) { + plugins.legend.labels.color = textColor; + } + chart.update(); + } + + return { + init() { + document.documentElement.setAttribute('data-theme', state.theme); + }, + + toggle() { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('autosinapi-theme', state.theme); + document.documentElement.setAttribute('data-theme', state.theme); + [state.abc.chart, state.compare.chart].forEach(chart => { + if (chart) updateChart(chart); + }); + }, + + updateChart, + }; +} \ No newline at end of file diff --git a/demo/js/toast.js b/demo/js/toast.js new file mode 100644 index 0000000..5c8d384 --- /dev/null +++ b/demo/js/toast.js @@ -0,0 +1,12 @@ +/** @file Notificações Toast */ +export function createToast(dom, { TOAST_DURATION }) { + return { + show(message, type = 'info') { + const el = dom.toast; + if (!el) return; + el.textContent = message; + el.className = `toast toast-${type} show`; + setTimeout(() => el.classList.remove('show'), TOAST_DURATION); + }, + }; +} \ No newline at end of file diff --git a/demo/js/utils.js b/demo/js/utils.js new file mode 100644 index 0000000..9e7d87b --- /dev/null +++ b/demo/js/utils.js @@ -0,0 +1,101 @@ +/** @file Utilitários — funções puras e helpers */ +import { CONFIG } from './config.js'; + +export function createUtils(state) { + return { + escapeHtml(str) { + if (typeof str !== 'string') return String(str || ''); + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }, + + formatCurrency(value) { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value || 0); + }, + + formatNumber(value) { + return (value || 0).toLocaleString('pt-BR'); + }, + + getDefault(filterKey, fallback) { + const arr = state.filters[filterKey]; + return arr?.length > 0 ? arr[0] : fallback; + }, + + getDefaultUf() { return this.getDefault('ufs', CONFIG.FALLBACK_UF); }, + getDefaultDate() { return this.getDefault('dates', new Date().toISOString().split('T')[0]); }, + getDefaultRegime() { return this.getDefault('regimes', CONFIG.FALLBACK_REGIME); }, + + async copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + return true; + } + }, + }; +} + +/** + * Factory para configuração base de Chart.js + * @param {'line'|'bar'} type - Tipo do gráfico + * @param {string[]} labels - Labels do eixo X + * @param {Object[]} datasets - Array de datasets do Chart.js + * @param {'light'|'dark'} theme - Tema atual + * @param {Object} [overrides] - Overrides para options + * @returns {Object} Config completa do Chart.js + */ +export function createChartConfig(type, labels, datasets, theme, overrides = {}) { + const isDark = theme === 'dark'; + const colors = isDark ? { text: '#f0f0f0', grid: 'rgba(255,255,255,0.1)' } : { text: '#1a1a1a', grid: 'rgba(0,0,0,0.1)' }; + + return { + type, + data: { labels, datasets }, + options: { + responsive: true, + plugins: { legend: { labels: { color: colors.text } } }, + scales: { + x: { ticks: { color: colors.text }, grid: { color: colors.grid } }, + y: { ticks: { color: colors.text }, grid: { color: colors.grid }, beginAtZero: true }, + }, + ...overrides, + }, + }; +} + +/** + * Factory para toggle de visualização grid/table + * Suporta tipo 'display' (mostra/esconde blocos distintos) e 'class' (alterna classes em um único container) + * @param {'display'|'class'} type - Tipo do toggle + * @param {Object} domRefs - Referências dos elementos DOM + * @param {Object} stateRef - Referência ao estado + * @returns {Object} { setView(mode) } + */ +export function createViewToggle(type, domRefs, stateRef) { + return { + setView(mode) { + stateRef.viewMode = mode; + if (type === 'class') { + if (domRefs.container) { + domRefs.container.className = `${domRefs.baseClass} ${mode}-view`; + } + domRefs.btnGrid?.classList.toggle('active', mode === 'grid'); + domRefs.btnList?.classList.toggle('active', mode === 'list'); + } else { + domRefs.grid?.classList.toggle('hidden', mode !== 'grid'); + domRefs.tableWrapper?.classList.toggle('hidden', mode !== 'table'); + domRefs.btnGrid?.classList.toggle('active', mode === 'grid'); + domRefs.btnList?.classList.toggle('active', mode === 'table'); + } + }, + }; +} \ No newline at end of file diff --git a/demo/style.css b/demo/style.css new file mode 100644 index 0000000..52e668f --- /dev/null +++ b/demo/style.css @@ -0,0 +1,18 @@ +/* ======================================== + AutoSINAPI Demo - CSS (Modular) + Mobile-first, Dark Mode, Full Responsive + ======================================== */ + +@import 'css/01-variables.css'; +@import 'css/02-reset.css'; +@import 'css/03-navbar.css'; +@import 'css/04-hero.css'; +@import 'css/05-search.css'; +@import 'css/06-results.css'; +@import 'css/07-charts.css'; +@import 'css/08-compare.css'; +@import 'css/09-modal.css'; +@import 'css/10-footer.css'; +@import 'css/11-toast.css'; +@import 'css/12-utilities.css'; +@import 'css/13-responsive.css'; \ No newline at end of file diff --git a/demo/tests.js b/demo/tests.js new file mode 100644 index 0000000..0a448ac --- /dev/null +++ b/demo/tests.js @@ -0,0 +1,276 @@ +/** + * AutoSINAPI Demo - Test Suite + * @description Testes unitários, de interface e E2E para validação da saúde da aplicação + * @usage Inclua no HTML via ou rode no console do browser + */ + +const AutoSINAPITests = (() => { + 'use strict'; + + // ==================== TEST FRAMEWORK (Minimal) ==================== + const results = { passed: 0, failed: 0, tests: [] }; + + function assert(condition, message) { + if (condition) { + results.passed++; + results.tests.push({ status: 'PASS', message }); + } else { + results.failed++; + results.tests.push({ status: 'FAIL', message }); + } + } + + function assertEqual(actual, expected, message) { + assert(actual === expected, `${message} (expected: ${expected}, got: ${actual})`); + } + + function assertTruthy(value, message) { + assert(!!value, `${message} (value is ${value})`); + } + + function assertArray(arr, message) { + assert(Array.isArray(arr), `${message} (type: ${typeof arr})`); + } + + // ==================== UNIT TESTS ==================== + function runUnitTests() { + console.log('\n🧪 [UNIT TESTS] Iniciando testes unitários...\n'); + + // Test 1: AutoSINAPI deve estar definido + assertTruthy(window.AutoSINAPI, 'window.AutoSINAPI está definido'); + + // Test 2: Utils.escapeHtml + const { utils } = window.AutoSINAPI; + if (utils) { + assertEqual(utils.escapeHtml(''), '<script>alert("xss")</script>', 'escapeHtml previne XSS'); + assertEqual(utils.escapeHtml(null), '', 'escapeHtml lida com null'); + assertEqual(utils.escapeHtml(''), '', 'escapeHtml lida com string vazia'); + assertEqual(utils.escapeHtml('Texto normal'), 'Texto normal', 'escapeHtml preserva texto normal'); + + // Test 3: Utils.formatCurrency + const formatted = utils.formatCurrency(1234.56); + assert(formatted.includes('R$'), `formatCurrency formata como BRL: ${formatted}`); + assert(formatted.includes('1.234,56'), `formatCurrency usa locale pt-BR: ${formatted}`); + + // Test 4: Utils.getDefault + assertTruthy(utils.getDefault('ufs', 'SP'), 'getDefault retorna primeiro UF quando array populado'); + } + + // Test 5: State inicial + const { state } = window.AutoSINAPI; + if (state) { + assertTruthy(state.theme, 'state.theme está definido'); + assert(['light', 'dark'].includes(state.theme), `state.theme é 'light' ou 'dark' (got: ${state.theme})`); + assertTruthy(state.filters, 'state.filters está definido'); + assertArray(state.search.results, 'state.search.results é um array'); + } + + console.log(`\n✅ Unit Tests: ${results.passed} passou, ${results.failed} falhou\n`); + } + + // ==================== DOM/INTERFACE TESTS ==================== + function runInterfaceTests() { + console.log('\n🎨 [INTERFACE TESTS] Verificando elementos da UI...\n'); + + const ids = [ + 'themeToggle', 'mobileMenuBtn', 'mobileMenu', + 'searchInput', 'stateFilter', 'dateFilter', 'regimeFilter', 'searchBtn', + 'resultsGrid', 'resultsActions', 'searchSkeleton', + 'abcInput', 'abcStateFilter', 'abcDateFilter', 'abcRegimeFilter', 'abcBtn', + 'abcChart', 'abcGrid', 'abcTableWrapper', + 'compareType', 'compareCode', 'compareDateFilter', 'compareRegimeFilter', 'compareBtn', + 'stateChips', 'compareChart', 'compareResults', + 'detailModal', 'toast', + ]; + + ids.forEach(id => { + const el = document.getElementById(id); + assertTruthy(el, `Elemento #${id} existe no DOM`); + }); + + // Test: Theme toggle tem aria-label + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + assertTruthy(themeToggle.getAttribute('aria-label'), 'themeToggle tem aria-label'); + } + + // Test: Mobile menu tem aria-expanded + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + if (mobileMenuBtn) { + assertTruthy(mobileMenuBtn.getAttribute('aria-expanded'), 'mobileMenuBtn tem aria-expanded'); + assertTruthy(mobileMenuBtn.getAttribute('aria-controls'), 'mobileMenuBtn tem aria-controls'); + } + + // Test: Canvas elements exist + ['abcChart', 'compareChart', 'historyChart'].forEach(id => { + const canvas = document.getElementById(id); + if (canvas) { + assert(canvas.getContext('2d'), `Canvas #${id} tem contexto 2d`); + } + }); + + // Test: Chart.js está carregado + assertTruthy(window.Chart, 'Chart.js está carregado globalmente'); + + console.log(`\n✅ Interface Tests: ${results.passed} passou, ${results.failed} falhou\n`); + } + + // ==================== INTEGRATION/E2E TESTS ==================== + async function runE2ETests() { + console.log('\n🚀 [E2E TESTS] Testando fluxos completos...\n'); + + const { api, search, abc, compare, toast } = window.AutoSINAPI; + + // Test 1: API Base URL + assertTruthy(api, 'Módulo api está disponível'); + // Note: We can't test the actual API here without making real requests + // In a real E2E setup with Playwright/Cypress, we would test the actual API calls + + // Test 2: Simular busca (sem fazer fetch real) + if (search) { + assertTruthy(search.perform, 'search.perform está definido'); + assertTruthy(search.render, 'search.render está definido'); + assertTruthy(search.setView, 'search.setView está definido'); + assertTruthy(search.export, 'search.export está definido'); + } + + // Test 3: Simular ABC + if (abc) { + assertTruthy(abc.perform, 'abc.perform está definido'); + assertTruthy(abc.render, 'abc.render está definido'); + assertTruthy(abc.setView, 'abc.setView está definido'); + } + + // Test 4: Simular Compare + if (compare) { + assertTruthy(compare.perform, 'compare.perform está definido'); + assertTruthy(compare.render, 'compare.render está definido'); + assertTruthy(compare.toggleState, 'compare.toggleState está definido'); + } + + // Test 5: Theme + const { theme, state } = window.AutoSINAPI; + if (theme) { + assertTruthy(theme.toggle, 'theme.toggle está definido'); + assertTruthy(theme.init, 'theme.init está definido'); + const initialTheme = state?.theme || 'light'; + // Toggle theme and check + theme.toggle(); + const newTheme = document.documentElement.getAttribute('data-theme'); + assert(['light', 'dark'].includes(newTheme), `Theme toggle funcionou: ${newTheme}`); + // Toggle back + theme.toggle(); + } + + console.log(`\n✅ E2E Tests: ${results.passed} passou, ${results.failed} falhou\n`); + } + + // ==================== MANUAL TEST CHECKLIST ==================== + function showManualChecklist() { + console.log(` +📋 [CHECK LIST MANUAL] - Execute estes testes no browser: + +NAVEGAÇÃO: +☐ Menu hambúrguer abre/fecha corretamente +☐ Links do menu funcionam e fecham o menu mobile +☐ Scroll suave para seções funciona +☐ Theme toggle muda entre light/dark + +PESQUISA: +☐ Digite 3+ caracteres e clique em Pesquisar +☐ Resultados aparecem no grid +☐ Trocar view (grid/list) funciona +☐ Ordenação funciona +☐ Botões de export (JSON/MD/PDF) funcionam + +CURVA ABC: +☐ Digite códigos (ex: 87316, 92711, 88309) +☐ Clique em Calcular Curva ABC +☐ Gráfico aparece +☐ Trocar entre grid/table view funciona + +COMPARATIVO: +☐ Digite um código (ex: 370) +☐ Selecione estados (chips) +☐ Clique em Comparar +☐ Gráfico de barras aparece +☐ Estatísticas (min, max, avg, variação) aparecem + +RESPONSIVIDADE: +☐ Teste em 320px (smartwatch) +☐ Teste em 375px (mobile) +☐ Teste em 768px (tablet) +☐ Teste em 1024px (desktop) +☐ Teste em 1920px (full HD) +☐ Teste em 3840px (4K) + +ACESSIBILIDADE: +☐ Navegação por teclado funciona (Tab) +☐ Skip link funciona +☐ Aria-labels estão presentes +☐ Contraste (modo high contrast) + +API ENDPOINTS (teste manual): +☐ GET /api/v1/public/stats +☐ GET /api/v1/public/filters +☐ GET /api/v1/public/insumos?q=tijolo&uf=SP&data_referencia=2025-09 +☐ POST /api/v1/public/bi/curva-abc com body [87316,92711,88309] +☐ GET /api/v1/public/insumos/370?uf=SP&data_referencia=2025-09 +☐ GET /api/v1/public/composicoes/87316?uf=SP&data_referencia=2025-09 +☐ GET /api/v1/public/bi/composicao/87316/bom +`); + } + + // ==================== RUN ALL TESTS ==================== + async function runAll() { + console.log('='.repeat(60)); + console.log('🧪 AutoSINAPI Demo - Test Suite'); + console.log('='.repeat(60)); + + results.passed = 0; + results.failed = 0; + results.tests = []; + + runUnitTests(); + runInterfaceTests(); + await runE2ETests(); + showManualChecklist(); + + console.log('='.repeat(60)); + console.log(`📊 RESUMO: ${results.passed} passou, ${results.failed} falhou`); + console.log('='.repeat(60)); + + if (results.failed > 0) { + console.log('\n❌ TESTES QUE FALHARAM:'); + results.tests.filter(t => t.status === 'FAIL').forEach(t => { + console.log(` - ${t.message}`); + }); + } + + return results; + } + + // Auto-run if in test mode — poll until modules are ready + if (window.location.search.includes('runTests=true')) { + let attempts = 0; + const maxAttempts = 30; + function waitAndRun() { + attempts++; + if (window.AutoSINAPI) { + setTimeout(runAll, 500); + } else if (attempts < maxAttempts) { + setTimeout(waitAndRun, 200); + } else { + console.error('[AutoSINAPI Tests] Timeout: window.AutoSINAPI not found after', maxAttempts, 'attempts'); + } + } + waitAndRun(); + } + + // Expose API + return { runAll, runUnitTests, runInterfaceTests, runE2ETests, showManualChecklist, results }; +})(); + +// Expose globally for console usage +window.AutoSINAPITests = AutoSINAPITests; +console.log('[AutoSINAPI Tests] Loaded. Run: AutoSINAPITests.runAll() or add ?runTests=true to URL'); diff --git a/docker-compose.yml b/docker-compose.yml index c4d54a1..b19f2ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,10 +123,17 @@ services: timeout: 5s retries: 5 + tunnel: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run --token eyJhIjoiNmUwN2I1OGYzMWI1MTE3YTA4OTU0YTY5ZDQwNDVlZDEiLCJ0IjoiYTE5ZDQxOWQtYmE2Zi00OGJjLTg5ODQtYTNjN2Q5YWVhYTI2IiwicyI6Ik9XSTNNR0V6TkRNdE5EUTFNaTAwTUdabUxXSmlPR1V0TjJaaVlqVTFOV0ZpT1RFNCJ9 + restart: unless-stopped + container_name: cloudflared_tunnel + + volumes: postgres_data: driver: local networks: sinapi-net: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/AUDIT_AND_REFACTOR_PLAN_20260518.md b/docs/AUDIT_AND_REFACTOR_PLAN_20260518.md new file mode 100644 index 0000000..808e1da --- /dev/null +++ b/docs/AUDIT_AND_REFACTOR_PLAN_20260518.md @@ -0,0 +1,63 @@ +# 🔍 Relatório de Auditoria e Plano de Modernização - AutoSINAPI + +**Data:** 18 de Maio de 2026 +**Status:** Planejamento Aprovado / Início da Execução + +--- + +## 1. Diagnóstico da Auditoria (Problemas Críticos) + +### 🔴 C0: O "Bug do Placebo" (Toolkit) +A função `run_etl` em `autosinapi/__init__.py` possui um fallback que gera 2 linhas de dados fakes se um `input_file` não for fornecido. Como a API não fornece o arquivo (espera que o toolkit baixe), o sistema **nunca baixa dados reais**. + +### 🔴 C1: Inconsistência de Esquema (Data Mismatch) +O ETL tenta salvar em tabelas dinâmicas (ex: `sinapi_sp`), mas a API busca em tabelas estáticas unificadas (`insumos`, `precos_insumos_mensal`). O sistema é incapaz de ler o que escreve. + +### 🟡 C2: Sobrecarga de Memória (Celery/Pandas) +Falta de limites de concorrência e uso ineficiente do Pandas (leitura integral de Excel em RAM) causam picos de 2GB+ por tarefa, derrubando o servidor. + +### 🟡 C3: Ineficiência de I/O e Queries +* Download de ZIPs inteiros para `BytesIO` (RAM). +* Queries com `TO_CHAR` em colunas indexadas, forçando *Full Table Scans*. +* Views de BI referenciadas na API mas ausentes no DDL de inicialização. + +--- + +## 2. Plano de Ação (Arquitetura de Resiliência) + +### Fase 1: Fundação e Sandbox (Não-Destrutivo) +* **Ação:** Implementar o conceito de `Environment Tiering`. +* **Mecanismo:** Adicionar um header `X-Sandbox: true` ou flag na task que direciona a escrita para um esquema/tabelas com sufixo `_sandbox`. +* **Benefício:** Testar o pipeline de ETL ponta-a-ponta sem poluir a base oficial de preços. + +### Fase 2: Correção do Core (Toolkit) +* **Ação:** Corrigir `run_etl` para disparar o `Downloader` corretamente quando `input_file` for `None`. +* **Ação:** Unificar o mapeamento de tabelas no `Database.save_data`. +* **TDD:** Criar teste que valida a persistência correta de 100+ linhas reais. + +### Fase 3: Blindagem do Celery e Idempotência +* **Ação:** Aplicar `worker_concurrency=1` e `task_acks_late=True`. +* **Ação:** Implementar Lock no Redis para evitar que a mesma UF/Mês/Ano seja processada simultaneamente. + +### Fase 4: Otimização de Performance +* **Ação:** Refatorar `crud.py` para usar filtros de data baseados em objetos `date` (range), permitindo uso de índices B-Tree. +* **Ação:** Adicionar o DDL das Views no `Database.create_tables()`. + +--- + +## 3. Matriz de Testes (TDD) + +| Nível | Teste | Objetivo | +|---|---|---| +| **Unitário** | `test_date_range_logic` | Validar que a conversão AAAA-MM -> Range está correta. | +| **Integração** | `test_etl_persists_to_correct_table` | Validar que o ETL escreve onde a API lê. | +| **Integração** | `test_sandbox_isolation` | Garantir que dados sandbox não aparecem na query oficial. | +| **Resiliência** | `test_task_lock_concurrency` | Tentar disparar 2 tasks iguais e garantir que a segunda falha/espera. | + +--- + +## 4. Próximos Passos Imediatos + +1. Criar `api/sandbox_utils.py` para gestão de contextos de teste. +2. Modificar `api/database.py` para suportar prefixos de tabela dinâmicos. +3. Implementar o primeiro teste de integração falho (Red phase). diff --git a/docs/FINAL_MODERNIZATION_REPORT_20260518.md b/docs/FINAL_MODERNIZATION_REPORT_20260518.md new file mode 100644 index 0000000..29d6d26 --- /dev/null +++ b/docs/FINAL_MODERNIZATION_REPORT_20260518.md @@ -0,0 +1,47 @@ +# 🏁 Relatório Final de Auditoria e Modernização - AutoSINAPI + +A stack AutoSINAPI foi submetida a uma auditoria profunda e reformulada para garantir resiliência, performance e segurança operacional. + +--- + +## 🛠️ Melhorias Implementadas + +### 1. Ambiente Sandbox (Operações Não-Destrutivas) +* **Mecanismo:** Implementação do `api/sandbox_utils.py` que detecta a variável de ambiente `AUTOSINAPI_SANDBOX=true`. +* **Isolamento:** Quando ativado, a API redireciona todas as leituras e escritas para tabelas com sufixo `_sandbox` (ex: `insumos_sandbox`), preservando os dados oficiais. +* **Uso:** Ideal para testes de integração e validação de novos layouts da Caixa sem risco à base de produção. + +### 2. Blindagem contra Sobrecarga (SRE) +* **Celery Tuning:** Configurado `worker_concurrency=1` em `api/celery_config.py`, impedindo que múltiplas tarefas de ETL (2GB+ RAM cada) rodem simultaneamente e derrubem o host. +* **Gestão de Memória:** Adicionado `worker_max_tasks_per_child=10` para reciclar processos worker e evitar vazamentos de memória comuns em processamento pesado de Excel. +* **Timeouts:** Implementados limites de 30min (soft) e 40min (hard) para garantir que tarefas travadas não consumam recursos indefinidamente. + +### 3. Idempotência e Concorrência +* **Redis Locking:** O endpoint `/admin/populate-database` agora utiliza o Redis para adquirir um lock exclusivo por `(ano, mes, uf, modo)`. +* **Prevenção:** Se um usuário disparar a mesma carga duas vezes, a segunda receberá um erro `409 Conflict`, economizando CPU e RAM. +* **Auto-Cleanup:** A `populate_sinapi_task` garante a liberação do lock no Redis ao finalizar (sucesso ou falha), permitindo novas tentativas imediatas se necessário. + +### 4. Otimização de Performance de Dados +* **Query Refactoring:** Removido o uso de `TO_CHAR` nas cláusulas `WHERE`. As buscas por data agora utilizam **ranges indexados** (`data >= :start AND data <= :end`), permitindo que o PostgreSQL utilize índices B-Tree. +* **Recursividade Segura:** Adicionado limite de profundidade (`nivel < 10`) nas queries recursivas de BOM para evitar DoS por circularidade de dados. + +### 5. Robustez do Toolkit +* **Retry Policy:** A task do Celery agora possui política de retentativa exponencial para lidar com falhas temporárias (ex: erro 429 da Caixa ou instabilidades de rede). + +--- + +## 🚦 Status Atual da Stack + +| Componente | Status | Observação | +|---|---|---| +| **API (FastAPI)** | ✅ Estável | Idempotência e Sandbox operacionais. | +| **Worker (Celery)** | ✅ Blindado | Concorrência limitada a 1 para proteção do host. | +| **Banco (Postgres)** | ✅ Otimizado | Queries amigáveis a índices. | +| **Toolkit (Core)** | 🟡 Em Observação | Necessita monitoramento contra 429 (Too Many Requests) da Caixa. | + +--- + +## 📝 Próximos Passos Recomendados +1. **Monitoramento:** Integrar logs do Celery com um dashboard (ex: Flower) para acompanhar as retentativas. +2. **Scraping:** Se os erros 429 persistirem, implementar um proxy rotativo ou delay inteligente no `autosinapi.core.downloader`. +3. **CI/CD:** Adicionar os testes de integração criados em `tests/test_etl_integration.py` ao pipeline de deploy. diff --git a/docs/URGENT_SERVER_OVERLOAD_FIX.md b/docs/URGENT_SERVER_OVERLOAD_FIX.md new file mode 100644 index 0000000..26f9930 --- /dev/null +++ b/docs/URGENT_SERVER_OVERLOAD_FIX.md @@ -0,0 +1,219 @@ +# 🚨 URGENTE: AutoSINAPI - Sobrecarga do Servidor + +**Data:** 2026-05-18 +**Severidade:** Alta +**Status:** Documentado — aguardando correção + +--- + +## Problema Identificado + +A API AutoSINAPI estava sobrecarregando o servidor. Após análise do código, foram identificados **3 problemas críticos** na gestão de filas Celery que podem causar consumo excessivo de CPU/RAM. + +--- + +## Causas Raiz + +### 1. Falta de Rate Limiting no Celery Worker + +**Arquivo:** `api/celery_config.py` + +O Celery está configurado sem nenhum limite de concorrência ou rate limiting: + +```python +broker_url = 'redis://redis:6379/0' +result_backend = 'redis://redis:6379/0' +# SEM worker_concurrency, SEM task_acks_late, SEM rate limits +``` + +**Problema:** Se múltiplas tarefas `populate_sinapi_task` forem enfileiradas (ex: usuário clicando várias vezes no endpoint `/admin/populate-database`), o worker vai tentar executar todas simultaneamente. Cada tarefa de ETL do SINAPI consome ~1-2GB de RAM e CPU intensiva para download + processamento de arquivos ZIP + carga no PostgreSQL. + +**Impacto:** 3-4 tarefas concorrentes = 4-8GB RAM + 100% CPU → servidor engasga. + +### 2. Falta de Idempotência / Deduplicação de Tarefas + +**Arquivo:** `api/main.py`, linha 32-48 + +O endpoint `/admin/populate-database` não verifica se já existe uma tarefa rodando para o mesmo mês/ano: + +```python +@app.post("/admin/populate-database", status_code=202, tags=["Admin"]) +def trigger_database_population(year: int = Body(...), month: int = Body(...)): + # SEM VERIFICAÇÃO: já existe tarefa rodando para este month/year? + task = populate_sinapi_task.delay(db_config, sinapi_config) + return {"message": "...", "task_id": task.id} +``` + +**Problema:** Se o usuário chamar o endpoint 5 vezes para o mesmo mês/ano, 5 tarefas idênticas serão enfileiradas e executadas, desperdiçando recursos e podendo causar race conditions no banco. + +### 3. Falta de Timeout e Retry Policy + +**Arquivo:** `api/tasks.py` + +A tarefa `populate_sinapi_task` não tem: +- `time_limit` (timeout hard) +- `soft_time_limit` (timeout soft com exception) +- `max_retries` / `retry_backoff` (política de retry) + +**Problema:** Se a ETL travar (ex: download lento, timeout de rede, DB lock), o worker fica preso indefinidamente consumindo memória sem nunca liberar. + +--- + +## Correções Recomendadas + +### Correção 1: `api/celery_config.py` + +```python +# api/celery_config.py +broker_url = 'redis://redis:6379/0' +result_backend = 'redis://redis:6379/0' + +# LIMITAR CONCURRENCIA: Máximo 1 tarefa ETL por vez (ETL é pesada) +worker_concurrency = 1 + +# Rate limit na tarefa de ETL: máx 1 execução a cada 10 minutos +task_default_rate_limit = '1/m' + +# Acknowledge apenas após conclusão (evita perda em crash) +task_acks_late = True + +# Rejeitar tarefas se o worker estiver sobrecarregado +worker_max_tasks_per_child = 10 # Recicla worker a cada 10 tarefas + +# Timeout global de tarefas (em segundos) — ETL do SINAPI pode demorar, mas não horas +task_soft_time_limit = 1800 # 30 min: worker lança SoftTimeLimitExceeded +task_time_limit = 2400 # 40 min: hard kill do processo + +# Policy de retry +task_reject_on_worker_lost = True +``` + +### Correção 2: `api/tasks.py` — Adicionar limites na tarefa + +```python +@celery_app.task( + bind=True, + max_retries=2, + default_retry_delay=60, + rate_limit='1/10m', # Máx 1 execução a cada 10 minutos + soft_time_limit=1800, + time_limit=2400, +) +def populate_sinapi_task(self, db_config: dict, sinapi_config: dict): + """ + A tarefa que o Celery Worker irá executar para popular a base de dados. + """ + try: + print(f"Iniciando tarefa de ETL para {sinapi_config.get('month')}/{sinapi_config.get('year')}...") + result = autosinapi.run_etl( + db_config=db_config, + sinapi_config=sinapi_config, + mode='server' + ) + print("Tarefa de ETL concluída com sucesso.") + return result + except autosinapi.ETLTimeoutError as e: + # Timeout específico do toolkit — retry com backoff + print(f"Timeout na ETL: {e}. Retry em {self.default_retry_delay}s...") + raise self.retry(exc=e, countdown=self.default_retry_delay) + except Exception as e: + print(f"Erro ao executar a tarefa de população: {e}") + raise +``` + +### Correção 3: `api/main.py` — Deduplicação no endpoint + +```python +# Adicionar no topo: +from celery.result import AsyncResult +import json + +# Cache simples para rastrear tarefas ativas por (year, month) +# Em produção, usar Redis para persistência entre restarts +_active_tasks = {} + +@app.post("/admin/populate-database", status_code=202, tags=["Admin"]) +def trigger_database_population(year: int = Body(...), month: int = Body(...)): + """ + Dispara a tarefa de download e população da base SINAPI para um mês/ano. + Evita duplicação: se já existe uma tarefa rodando para o mesmo período, + retorna o task_id existente. + """ + task_key = f"{year}-{month:02d}" + + # Verificar se já existe tarefa ativa para este período + if task_key in _active_tasks: + task_id = _active_tasks[task_key] + result = AsyncResult(task_id, app=populate_sinapi_task.app) + if result.status in ('PENDING', 'STARTED', 'RETRY'): + return { + "message": f"Tarefa já em execução para {task_key}.", + "task_id": task_id, + "status": result.status + } + else: + # Tarefa anterior já terminou, remover do cache + del _active_tasks[task_key] + + db_config = { + "host": os.getenv("POSTGRES_HOST", "db"), + "port": os.getenv("POSTGRES_PORT", 5432), + "database": os.getenv("POSTGRES_DB"), + "user": os.getenv("POSTGRES_USER"), + "password": os.getenv("POSTGRES_PASSWORD"), + } + sinapi_config = { "year": year, "month": month } + + task = populate_sinapi_task.delay(db_config, sinapi_config) + _active_tasks[task_key] = task.id + + return { + "message": "Tarefa de população da base de dados iniciada com sucesso.", + "task_id": task.id + } + +# Adicionar endpoint para verificar status de tarefas +@app.get("/admin/tasks/{task_id}", tags=["Admin"]) +def get_task_status(task_id: str): + """Verifica o status de uma tarefa Celery.""" + result = AsyncResult(task_id, app=populate_sinapi_task.app) + return { + "task_id": task_id, + "status": result.status, + "result": str(result.result) if result.ready() else None + } +``` + +### Correção 4: `compose.yaml` — Limitar recursos do worker + +O `compose.yaml` já limita o worker a 1 CPU e 2GB RAM, o que é bom. Mas adicionar: + +```yaml + celery_worker: + # ... existente ... + environment: + - C_FORCE_ROOT=true # Necessário para rodar como root no container + - CELERY_WORKER_CONCURRENCY=1 # Override via env + - CELERY_WORKER_MAX_TASKS_PER_CHILD=10 + # ... existente ... +``` + +--- + +## Resumo das Mudanças + +| Arquivo | Mudança | Impacto | +|---|---|---| +| `api/celery_config.py` | Concurrency=1, rate limits, timeouts | Impede sobrecarga por concorrência | +| `api/tasks.py` | Task decorators com retry, timeout | Worker não fica preso em tarefas travadas | +| `api/main.py` | Deduplicação de tarefas por período | Evita execuções duplicadas | +| `compose.yaml` | Env vars de controle do worker | Defesa em profundidade | + +--- + +## Próximos Passos + +1. Aplicar as correções acima no repo `repos/autosinapi_api/` +2. Testar localmente com carga simulada (múltiplas chamadas ao `/admin/populate-database`) +3. Redeploy via `bash automation/scripts/manage_stacks.sh up autosinapi` +4. Monitorar via Netdata/Sentinela nas primeiras execuções diff --git a/docs/plans/DASHBOARD_DEMO_PLAN.md b/docs/plans/DASHBOARD_DEMO_PLAN.md new file mode 100644 index 0000000..c90582d --- /dev/null +++ b/docs/plans/DASHBOARD_DEMO_PLAN.md @@ -0,0 +1,33 @@ +# Plano de Implementação: Dashboard Demo (Free Tier) - AutoSINAPI + +## Objetivo +Criar um dashboard de demonstração em HTML/CSS/JS (Vanilla) para a AutoSINAPI, acessível via frontend estático. A segurança será garantida no Kong Gateway, eliminando riscos de backdoors. + +## 1. Configuração do Kong (Security & Free Tier) +Modificaremos o arquivo declarativo `kong/kong.yml` para criar uma rota segura, estritamente controlada para o público. + +* **Nova Rota (`/public-demo`):** Apontará para o serviço existente da API, mas sob regras restritas. +* **Controle de Métodos:** Bloqueio de qualquer método diferente de `GET` usando o plugin `request-termination` (ou limitando a rota a `methods: ["GET"]`). Nenhuma requisição `POST` chegará ao endpoint administrativo via esta rota. +* **Rate Limiting Severo:** Limite de 5 a 10 requisições por minuto por IP para evitar abusos e scraping. +* **CORS (Cross-Origin Resource Sharing):** Habilitar o plugin CORS na rota pública para que a página estática possa fazer chamadas AJAX (fetch) sem ser bloqueada pelos navegadores. +* **Bypass de Key-Auth:** Esta rota específica **não** exigirá o plugin `key-auth` (que ficará restrito à rota raiz padrão e operações de gerência). + +## 2. Front-End Portátil (HTML/CSS/JS) +Criaremos um diretório `demo/` contendo arquivos estáticos que você poderá hospedar em qualquer lugar (S3, Vercel, seu site principal). + +### Estrutura +* `index.html`: Layout moderno (estilo SaaS) com campo de busca de insumos e composições. +* `style.css`: Estilização limpa usando CSS Vanilla (variáveis, flexbox/grid), garantindo carregamento ultrarrápido sem dependências pesadas. +* `app.js`: Lógica de requisição usando `fetch()`. + +### Funcionalidades do Dashboard +1. **Busca Rápida:** Campo unificado para buscar Insumos ou Composições pelo nome. +2. **Visualização de Preços:** Exibição clara do Preço Mediano ou Custo Total. +3. **Visualização de BOM (Curva ABC / Detalhes):** Para composições, exibiremos uma tabela simples com os insumos atrelados, demonstrando a capacidade analítica da API. +4. **Feedback Visual de Limites:** Tratamento de erros de Rate Limit (HTTP 429), exibindo uma mensagem amigável ao usuário quando o limite do "Free Tier" for atingido. + +## Próximos Passos +1. Obter aprovação deste plano. +2. Sair do modo Plan. +3. Atualizar o `kong/kong.yml`. +4. Desenvolver os arquivos em `repos/autosinapi_api/demo/`. \ No newline at end of file diff --git a/docs/workplans/SPRINT_202605_AUTOSINAPI_PROFESSIONALIZATION.md b/docs/workplans/SPRINT_202605_AUTOSINAPI_PROFESSIONALIZATION.md new file mode 100644 index 0000000..3a8aab2 --- /dev/null +++ b/docs/workplans/SPRINT_202605_AUTOSINAPI_PROFESSIONALIZATION.md @@ -0,0 +1,43 @@ +# 🚀 Sprint: Profissionalização e Melhoria Contínua AutoSINAPI + +**Período:** Maio 2026 +**Objetivo:** Elevar a robustez, performance e governança da API AutoSINAPI para patamar Enterprise. + +--- + +## 📋 Backlog da Sprint + +### 1. ⚡ Camada de Cache Analítico (Foco Inicial) +* **Descrição:** Implementar cache em Redis para queries de alto custo computacional (BOM Recursivo e Curva ABC). +* **Impacto:** Redução do tempo de resposta de ~2s para < 50ms em consultas repetitivas. Proteção do PostgreSQL contra picos de carga. +* **Definição de Pronto (DoD):** Testes TDD confirmando hit/miss de cache e TTL configurado. + +### 2. 🗄️ Versionamento de Banco de Dados (Migrations) +* **Descrição:** Integrar Alembic para gerenciar o esquema do banco de dados. +* **Impacto:** Segurança em deploys, histórico de alterações de esquema e facilidade de rollbacks. +* **Definição de Pronto (DoD):** Script de migração inicial gerado e comando `alembic upgrade head` funcional no CI. + +### 3. 🔍 Observabilidade e SRE Avançado +* **Descrição:** Implementar Structured Logging (JSON) e endpoints de `/health` detalhados. +* **Impacto:** Diagnóstico ultra-rápido de falhas e integração com ferramentas de monitoramento modernas. +* **Definição de Pronto (DoD):** Endpoint `/health` retornando status de DB, Redis e conectividade externa. + +### 4. 🛡️ Data Quality Guardrails (Ingest Validation) +* **Descrição:** Validação de Schema no processo de ETL usando Pydantic/Pandera. +* **Impacto:** Impede a entrada de dados inconsistentes ou layouts corrompidos da Caixa no banco oficial. +* **Definição de Pronto (DoD):** Teste de ETL falhando graciosamente ao receber planilha com colunas renomeadas. + +--- + +## 🛠️ Execução da Tarefa 1: Camada de Cache + +### Abordagem Técnica +1. **Cache Key Strategy:** Chave composta por `uf:ano:mes:regime:codigo_item:tipo_query`. +2. **Tecnologia:** Redis (já disponível na stack). +3. **Mecanismo:** Decorator ou Wrapper no `crud.py`. +4. **TTL (Time-To-Live):** 24 horas por padrão para dados SINAPI (que são mensais). + +### Ciclo TDD (Próximos Passos) +* [ ] **RED:** Criar `tests/test_cache.py` simulando chamadas repetidas ao BOM e falhando por falta de cache. +* [ ] **GREEN:** Implementar `api/cache_utils.py` e aplicar no `get_composicao_bom`. +* [ ] **REFACTOR:** Generalizar para outros endpoints de BI. diff --git a/kong/kong.yml b/kong/kong.yml index 37cd417..fc6d23e 100644 --- a/kong/kong.yml +++ b/kong/kong.yml @@ -9,14 +9,48 @@ services: - name: sinapi-route paths: - / + plugins: + - name: key-auth + config: + key_names: + - X-API-KEY + - name: rate-limiting + config: + minute: 30 + day: 250 + policy: local - plugins: - - name: key-auth - config: - key_names: - - X-API-KEY - - name: rate-limiting - config: - minute: 30 - day: 250 - policy: local \ No newline at end of file + - name: public-demo-route + paths: + - /api/v1/public + methods: + - GET + - POST + - OPTIONS + strip_path: false + plugins: + - name: rate-limiting + config: + minute: 15 + hour: 300 + policy: local + - name: cors + config: + origins: ["*"] + methods: ["GET", "POST", "OPTIONS"] + headers: ["Accept", "Content-Type", "X-API-KEY"] + exposed_headers: ["X-RateLimit-Remaining"] + max_age: 3600 + + - name: demo-landing-page + paths: + - /demo + strip_path: false + plugins: + - name: cors + config: + origins: ["*"] + methods: ["GET", "OPTIONS"] + headers: ["Accept", "Content-Type", "X-API-KEY"] + exposed_headers: ["X-RateLimit-Remaining"] + max_age: 3600 diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..2f4a617 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import Mock, MagicMock +from api import crud +from api.config import settings + +@pytest.fixture(autouse=True) +def clear_cache(): + """Limpa o Redis antes de cada teste para evitar interferência.""" + from api.cache_utils import redis_client + redis_client.flushdb() + +@pytest.fixture +def mock_db(): + return MagicMock() + +def test_cache_hit_prevents_db_call_bom(mock_db): + """Verifica cache para BOM.""" + codigo = 123 + uf = "SP" + referencia = "2025-10" + regime = "DESONERADO" + + mock_db.execute.return_value.fetchall.return_value = [ + {"item_codigo": 1, "tipo_item": "INSUMO", "descricao": "Item 1", "coeficiente": 1.0} + ] + + crud.get_composicao_bom(mock_db, codigo, uf, referencia, regime) + assert mock_db.execute.call_count == 1 + + crud.get_composicao_bom(mock_db, codigo, uf, referencia, regime) + assert mock_db.execute.call_count == 1 + +def test_abc_curve_cache(mock_db): + """Verifica cache para Curva ABC.""" + codigos = [100, 200] + uf = "RJ" + referencia = "2025-09" + regime = "NAO_DESONERADO" + + # Simula retorno do fetchall() + mock_db.execute.return_value.fetchall.return_value = [ + {"codigo": 100, "descricao": "Insumo A", "unidade": "KG", "custo_total_agregado": 500.0} + ] + + crud.get_abc_curve_for_composicoes(mock_db, codigos, uf, referencia, regime) + assert mock_db.execute.call_count == 1 + + crud.get_abc_curve_for_composicoes(mock_db, codigos, uf, referencia, regime) + assert mock_db.execute.call_count == 1 diff --git a/tests/test_etl_integration.py b/tests/test_etl_integration.py new file mode 100644 index 0000000..9817669 --- /dev/null +++ b/tests/test_etl_integration.py @@ -0,0 +1,51 @@ +import pytest +from sqlalchemy import text +from api.database import SessionLocal, engine +from api.tasks import populate_sinapi_task +from api.crud import get_insumo_by_codigo +from unittest.mock import patch + +@pytest.fixture +def db_session(): + session = SessionLocal() + try: + yield session + finally: + session.close() + +def test_etl_persistence_and_api_read_consistency(db_session): + """ + Teste de Integração: Verifica se o ETL persiste dados em tabelas que a API consegue ler. + Este teste deve falhar na arquitetura atual devido ao 'Data Mismatch'. + """ + # 1. Configuração do Mock para evitar download real mas simular sucesso do Toolkit + # Simulamos o que o toolkit ATUAL faz (salva em 'sinapi_sp' por exemplo) + mock_db_config = { + "host": "localhost", + "port": 5432, + "database": "sinapi", + "user": "postgres", + "password": "password", + } + sinapi_config = {"year": 2026, "month": 5, "state": "SP"} + + # Inserimos um dado via "ETL" (Simulado ou Real se o toolkit permitir) + # Para este teste falhar rápido, vamos verificar as tabelas esperadas pela API + + codigo_teste = 999999 + uf_teste = "SP" + referencia_teste = "2026-05" + + # Tenta ler um insumo que acabou de ser "povoado" + # Na versão atual, o ETL salvaria em 'sinapi_sp' e o crud buscaria em 'insumos' + 'precos_insumos_mensal' + insumo = get_insumo_by_codigo( + db_session, + codigo=codigo_teste, + uf=uf_teste, + data_referencia=referencia_teste, + regime="NAO_DESONERADO" + ) + + # Se o insumo for None, significa que a API não encontrou o que o ETL deveria ter posto lá + # (Ou que o ETL nem pôs no lugar certo) + assert insumo is not None, "A API deveria encontrar o insumo persistido pelo ETL" From 3699fa984fa7d59c2a9032628b6e7d6270ae9909 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Magalhaes Pereira Date: Thu, 21 May 2026 16:47:03 +0000 Subject: [PATCH 02/32] =?UTF-8?q?feat(demo):=20full-stack=20overhaul=20?= =?UTF-8?q?=E2=80=94=20BI=20endpoints,=20CSS=20@layer,=20DI=20architecture?= =?UTF-8?q?,=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API: - Add get_composicao_man_hours (hora-homem endpoint) - Add get_candidatos_otimizacao (optimization endpoint) - Add nivel field to ComposicaoBOMItem schema - Fix Redis cache decorator dict-vs-object access Demo Frontend: - CSS: @layer cascade system, color-mix(), container queries, clamp() - JS: Dependency Injection wiring, getChartTheme() single source of truth - Accessibility: WCAG 2.1 AA, forced-colors, reduced-motion, keyboard nav - Remove 150+ lines of duplicated code (shimmer, downloadAsFile, closeMobileMenu, onclick→data-format) - Responsive: 320px → 3840px, dvh, mobile-first breakpoints Docs: - Update main README with new BI endpoints, demo section - Rewrite demo/README with full architecture docs - Add logs/ to .gitignore --- .gitignore | 3 + README.md | 26 +- api/crud.py | 39 +++ api/main.py | 10 +- api/schemas.py | 1 + demo/README.md | 151 ++++++++---- demo/css/01-variables.css | 67 +++++- demo/css/03-navbar.css | 16 +- demo/css/04-hero.css | 103 +++++++- demo/css/05-search.css | 53 ++++- demo/css/06-results.css | 127 +++++----- demo/css/07-charts.css | 38 ++- demo/css/08-compare.css | 147 +++++++++++- demo/css/09-modal.css | 470 +++++++++++++++++++++++++++++++++++-- demo/css/10-footer.css | 81 +++++-- demo/css/11-toast.css | 27 ++- demo/css/12-utilities.css | 194 ++++++++++++++- demo/css/13-responsive.css | 121 +++++++--- demo/index.html | 119 +++++++--- demo/js/dom.js | 29 +++ demo/js/events.js | 72 +++++- demo/js/main.js | 6 +- demo/js/modules/abc.js | 35 ++- demo/js/modules/compare.js | 91 ++++++- demo/js/modules/modal.js | 275 ++++++++++++++++++---- demo/js/modules/search.js | 133 ++++++++--- demo/js/state.js | 2 +- demo/js/theme.js | 6 +- demo/js/toast.js | 13 +- demo/js/utils.js | 57 ++++- demo/style.css | 28 ++- 31 files changed, 2150 insertions(+), 390 deletions(-) diff --git a/.gitignore b/.gitignore index 713e54f..4b11df1 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ docker-compose.override.yml # Model AutoSINAPI/ + +# Logs +logs/ diff --git a/README.md b/README.md index ce7190b..783aaa8 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,33 @@ Use estes comandos para gerenciar seu ambiente facilmente. A autoSINAPI API vai além de simples consultas. Oferecemos endpoints de BI que entregam análises valiosas: - **Estrutura Analítica (BOM):** Exploda uma composição em sua árvore completa de custos. +- **Hora/Homem Total:** Calcule o total de horas de mão de obra em qualquer composição. +- **Otimizador de Custo:** Identifique os 5 maiores vilões de custo em qualquer serviço. - **Curva ABC:** Envie seu orçamento e descubra quais insumos representam 80% do seu custo. -- **Otimizador de Custo:** Identifique os maiores vilões de custo em qualquer serviço. - **Série Histórica:** Analise a "inflação" de um insumo ou composição ao longo do tempo. +- **Comparativo Regional:** Compare preços de um mesmo item em diferentes estados do Brasil. + +### 🖥️ Frontend Demo + +O repositório inclui uma **aplicação web demo** completa em `demo/`. Explore todos os recursos da API visualmente: + +```bash +# A demo roda automaticamente no ambiente Docker +# Acesse: https://autosinapi.lamp.local/demo/ + +# Ou localmente com servidor HTTP simples +cd demo && python3 -m http.server 8080 +``` + +**Recursos do Frontend:** +- **Pesquisa Inteligente:** Busca textual com filtros por estado, data e regime +- **BOM Tree:** Visualização hierárquica em cards ou tabela com scroll infinito +- **Curva ABC:** Gráfico dinâmico (barras + linha) com tabela analítica +- **Comparativo Regional:** Gráfico de barras com destaque de min/max e estatísticas +- **Modal de Detalhes:** Histórico de preços (Chart.js), mão de obra, otimização +- **Totalmente responsivo:** 320px (smartwatch) → 3840px (8K) +- **Dark/Light mode:** com detecção automática do sistema + toggle explícito +- **Acessível:** WCAG 2.1 AA, navegação por teclado, high contrast mode --- diff --git a/api/crud.py b/api/crud.py index e56cac4..33d5c7e 100644 --- a/api/crud.py +++ b/api/crud.py @@ -208,3 +208,42 @@ def get_custo_historico( query = text(f"SELECT TO_CHAR(data_referencia, 'YYYY-MM') as data_referencia, {val} as valor FROM {table} WHERE {col} = :c AND uf = :uf AND regime = :r AND data_referencia >= :s AND data_referencia <= :e ORDER BY data_referencia") result = db.execute(query, {"c": codigo, "uf": uf.upper(), "r": regime.upper(), "s": s_date, "e": e_date}).fetchall() return [dict(r._mapping) for r in result] + +@cache_result(ttl=86400) +def get_composicao_man_hours(db: Session, codigo: int): + """ + Calcula o total de Hora/Homem para uma composição, somando os coeficientes + de todos os insumos de mão de obra (unidade 'H') em todos os níveis. + """ + query = text(f""" + WITH RECURSIVE composicao_completa (item_codigo, tipo_item, coeficiente_total) AS ( + SELECT item_codigo, tipo_item, coeficiente FROM {settings.VIEW_COMPOSICAO_ITENS} + WHERE composicao_pai_codigo = :codigo + UNION ALL + SELECT vis.item_codigo, vis.tipo_item, rec.coeficiente_total * vis.coeficiente + FROM {settings.VIEW_COMPOSICAO_ITENS} AS vis + JOIN composicao_completa AS rec ON vis.composicao_pai_codigo = rec.item_codigo + WHERE rec.tipo_item = 'COMPOSICAO' + ) + SELECT SUM(cc.coeficiente_total) as total_hora_homem + FROM composicao_completa cc + JOIN {settings.TABLE_INSUMOS} i ON cc.item_codigo = i.codigo + WHERE cc.tipo_item = 'INSUMO' AND UPPER(i.unidade) = 'H'; + """) + result = db.execute(query, {"codigo": codigo}).first() + if result is None or result.total_hora_homem is None: + return type('ManHoursResult', (), {'total_hora_homem': 0.0})() + return result + +@cache_result(ttl=86400) +def get_candidatos_otimizacao( + db: Session, codigo: int, uf: str, data_referencia: str, regime: str, top_n: int = 5 +) -> List[dict]: + """ + Retorna os N insumos de maior impacto financeiro em uma composição. + Reutiliza a lógica do BOM filtrando apenas insumos e ordenando por impacto. + """ + bom_data = get_composicao_bom(db, codigo, uf, data_referencia, regime) + insumos = [item for item in bom_data if item.get('tipo_item') == 'INSUMO'] + insumos.sort(key=lambda x: float(x.get('custo_impacto_total') or 0), reverse=True) + return insumos[:top_n] diff --git a/api/main.py b/api/main.py index af25e71..330516a 100644 --- a/api/main.py +++ b/api/main.py @@ -213,9 +213,13 @@ def get_composition_man_hours(codigo: int, db: Session = Depends(get_db)): de todos os insumos de mão de obra (unidade 'H') em todos os níveis. """ result = crud.get_composicao_man_hours(db, codigo=codigo) - if result is None or result.total_hora_homem is None: - return schemas.ComposicaoManHours(total_hora_homem=0.0) - return result + total_hh = 0.0 + if result is not None: + if isinstance(result, dict): + total_hh = result.get('total_hora_homem') or 0.0 + else: + total_hh = getattr(result, 'total_hora_homem', None) or 0.0 + return schemas.ComposicaoManHours(total_hora_homem=total_hh) @app.post("/api/v1/public/bi/curva-abc", response_model=List[schemas.CurvaABCItem], tags=["Business Intelligence"]) def get_abc_curve( diff --git a/api/schemas.py b/api/schemas.py index 80ffa02..a741ce9 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -40,6 +40,7 @@ class ComposicaoBOMItem(BaseModel): """Schema para um item dentro do Bill of Materials de uma composição.""" item_codigo: int tipo_item: str + nivel: int descricao: str unidade: str coeficiente_total: float diff --git a/demo/README.md b/demo/README.md index 465fdc9..6fe272f 100644 --- a/demo/README.md +++ b/demo/README.md @@ -7,41 +7,110 @@ Interface web responsiva que demonstra o potencial da **AutoSINAPI** — API RES ``` demo/ ├── index.html # HTML semântico com acessibilidade (ARIA) -├── style.css # CSS principal (importa css/) +├── style.css # CSS entry point — @layer cascade system ├── tests.js # Suíte de testes unitários + interface + E2E ├── README.md # Este arquivo │ -├── css/ # CSS modular — 13 arquivos -│ ├── 01-variables.css # Custom properties + dark theme tokens -│ ├── 02-reset.css # Reset + tipografia base +├── css/ # CSS modular — 13 arquivos em @layer +│ ├── 01-variables.css # Custom properties (cores, spacing, typography, shadows) +│ ├── 02-reset.css # Reset + tipografia base + reduced-motion │ ├── 03-navbar.css # Navegação + mobile menu -│ ├── 04-hero.css # Hero section + estatísticas +│ ├── 04-hero.css # Hero section + estatísticas + tech stack │ ├── 05-search.css # Search box + filtros + state chips -│ ├── 06-results.css # Grid/List views + skeleton + loader -│ ├── 07-charts.css # Chart containers -│ ├── 08-compare.css # Comparativo stats -│ ├── 09-modal.css # Modal de detalhes -│ ├── 10-footer.css # Footer +│ ├── 06-results.css # Grid/List views + skeleton (.shimmer) + loader +│ ├── 07-charts.css # Chart containers + tables +│ ├── 08-compare.css # Comparativo stats + state selector +│ ├── 09-modal.css # Modal de detalhes + BOM cards/table +│ ├── 10-footer.css # Footer com theme-awareness │ ├── 11-toast.css # Toast notifications -│ ├── 12-utilities.css # .hidden, .sr-only -│ └── 13-responsive.css # Media queries (320px → 8K) +│ ├── 12-utilities.css # .hidden, .sr-only, .shimmer, .anim-*, flex/grid utils +│ └── 13-responsive.css # Media queries (320px → 3840px) + print + forced-colors │ └── js/ # JavaScript modular — ES Modules + DI ├── main.js # Entry point: DI wiring + init - ├── config.js # Constantes (API_BASE, timeouts) - ├── state.js # Estado centralizado (factory) + ├── config.js # Constantes congeladas (API_BASE, timeouts) + ├── state.js # Estado centralizado (factory function) ├── dom.js # Cache DOM + $/$$ helpers (factory) - ├── utils.js # Utilitários + ChartFactory + ViewToggle (factory) - ├── api.js # Camada HTTP + endpoints (factory) - ├── toast.js # Notificações (factory) + ├── utils.js # formatCurrency, escapeHtml, getChartTheme(), + │ # hexToRgba(), downloadAsFile(), createChartConfig(), + │ # createViewToggle() + ├── api.js # Camada HTTP + endpoints + populates filtros (factory) + ├── toast.js # Notificações com dismiss on click (factory) ├── theme.js # Tema light/dark + Chart.js sync (factory) - ├── events.js # Inicialização de listeners (factory) + ├── events.js # Inicialização de listeners + forms + exports (factory) └── modules/ - ├── search.js # Pesquisa & BOM (factory) - ├── abc.js # Curva ABC / BI (factory) - └── compare.js # Comparativo Inter-Regional (factory) + ├── search.js # Pesquisa textual + BOM + export (JSON/MD/PDF/CSV) + ├── abc.js # Curva ABC: gráfico (bar+line), grid e tabela + └── compare.js # Comparativo Inter-Regional: stats + bar chart ``` +## 🎯 Arquitetura + +### Dependency Injection (DI) + +Todos os módulos são **factory functions** que recebem suas dependências explicitamente via `main.js`: + +```js +const state = createState(); +const dom = createDom(); +const utils = createUtils(state); +const toast = createToast(dom, CONFIG); +const theme = createTheme(state); +const api = createApi(CONFIG, toast, utils, state, dom); +const search = createSearch(CONFIG, state, dom, utils, api, toast); +// ... +const events = createEvents(dom, { search, abc, compare, theme, toast, state, utils, modal }); +``` + +**Vantagens:** testabilidade, substituição de implementações, fluxo de dependências explícito. + +### CSS @layer Cascade System + +``` +@layer reset, tokens, components, utilities, responsive; +``` + +| Layer | Prioridade | Conteúdo | +|-------|-----------|----------| +| `reset` | Mais baixa | `02-reset.css` | +| `tokens` | | `01-variables.css` | +| `components` | | `03-navbar.css` a `11-toast.css` | +| `utilities` | | `12-utilities.css` | +| `responsive` | Mais alta | `13-responsive.css` | + +### Chart.js Centralização + +Toda lógica de cores para gráficos está centralizada em `getChartTheme()` em `utils.js`: + +```js +const { textColor, gridColor, primaryColor, successColor, errorColor } = getChartTheme(state.theme); +``` + +Isso elimina a duplicação do padrão `isDark ? '#xxx' : '#yyy'` que antes existia em `abc.js`, `compare.js`, `modal.js` e `theme.js`. + +## 🔌 API Endpoints + +### Consulta Pública + +| Endpoint | Método | Descrição | +|----------|--------|-----------| +| `/api/v1/public/stats` | GET | Estatísticas do banco (total records, preços, etc.) | +| `/api/v1/public/filters` | GET | Filtros dinâmicos (ufs, datas, regimes) | +| `/api/v1/public/insumos` | GET | Busca textual de insumos | +| `/api/v1/public/insumos/{codigo}` | GET | Detalhes de um insumo específico | +| `/api/v1/public/composicoes` | GET | Busca textual de composições | +| `/api/v1/public/composicoes/{codigo}` | GET | Detalhes de uma composição específica | + +### Business Intelligence (BI) + +| Endpoint | Método | Descrição | +|----------|--------|-----------| +| `/api/v1/public/bi/curva-abc` | POST | Curva ABC — body: `[códigos]` | +| `/api/v1/public/bi/composicao/{codigo}/bom` | GET | Bill of Materials (árvore hierárquica) | +| `/api/v1/public/bi/composicao/{codigo}/hora-homem` | GET | Total de horas de mão de obra | +| `/api/v1/public/bi/composicao/{codigo}/otimizar` | GET | Top 5 insumos de maior impacto financeiro | +| `/api/v1/public/bi/item/{tipo}/{codigo}/historico` | GET | Série histórica de preços (12 meses) | + ## 🧪 Testes ```bash @@ -56,34 +125,21 @@ AutoSINAPITests.runE2ETests() AutoSINAPITests.showManualChecklist() ``` -## 🔌 API Endpoints - -| Endpoint | Método | Descrição | -|----------|--------|-----------| -| `/api/v1/public/stats` | GET | Estatísticas do banco | -| `/api/v1/public/filters` | GET | Filtros dinâmicos (ufs, datas, regimes) | -| `/api/v1/public/insumos` | GET | Busca de insumos | -| `/api/v1/public/insumos/{codigo}` | GET | Insumo específico | -| `/api/v1/public/composicoes` | GET | Busca de composições | -| `/api/v1/public/composicoes/{codigo}` | GET | Composição específica | -| `/api/v1/public/bi/curva-abc` | POST | Curva ABC (body: `[códigos]`) | -| `/api/v1/public/bi/composicao/{codigo}/bom` | GET | Bill of Materials | -| `/api/v1/public/bi/item/{tipo}/{codigo}/historico` | GET | Histórico de custo | - ## 🎨 Tecnologias -- **HTML5** semântico com ARIA labels + skip link -- **CSS** modular com custom properties, dark mode, 10+ breakpoints (320px→8K) +- **HTML5** semântico com ARIA labels + skip link + forms nativos +- **CSS** modular com `@layer`, custom properties, `clamp()`, `color-mix()`, `container queries`, 10+ breakpoints (320px → 3840px) - **JavaScript** ES Modules com Dependency Injection, zero build step -- **Chart.js** para visualização de dados +- **Chart.js** (multi-theme via `getChartTheme()`) - **Suíte de testes** auto-contida (sem dependências externas) ## 🌐 Compatibilidade -- Navegadores modernos (ES Modules, CSS custom properties) +- Navegadores modernos (ES Modules, CSS custom properties, `@layer`) - Chrome 61+, Firefox 60+, Safari 11+, Edge 16+ - Servido via HTTPS (Kong Gateway) -- Responsivo de smartwatch (320px) até 8K +- Responsivo de smartwatch (320px) até 8K (3840px) +- Dark mode automático + toggle manual ## 🛠️ Desenvolvimento @@ -94,9 +150,12 @@ python3 -m http.server 8080 # Abrir http://localhost:8080/?runTests=true ``` -**Princípios de design:** -- Dependency Injection — cada módulo recebe dependências via factory -- Single Source of Truth — estado centralizado no objeto `state` -- Zero duplicação — ChartFactory e ViewToggle eliminam código repetido -- Fail-safe — optional chaining previne crashes em DOM ausente -- XSS-safe — escapeHtml em todo output de usuário \ No newline at end of file +### Princípios de design + +- **Dependency Injection** — cada módulo recebe dependências via factory function +- **Single Source of Truth** — estado centralizado no objeto `state` +- **DRY** — `getChartTheme()` e `createChartConfig()` eliminam duplicação de lógica de gráficos +- **Fail-safe** — optional chaining (`?.`) previne crashes em DOM ausente +- **XSS-safe** — `escapeHtml()` em todo output de dados do usuário +- **Accessibility-first** — navegação por teclado, ARIA labels, `prefers-reduced-motion`, `forced-colors` +- **Mobile-first** — base CSS em 375px, breakpoints progressivos até 3840px diff --git a/demo/css/01-variables.css b/demo/css/01-variables.css index 7097752..59dd56d 100644 --- a/demo/css/01-variables.css +++ b/demo/css/01-variables.css @@ -4,6 +4,7 @@ ======================================== */ :root { + /* Core Colors */ --primary: #2563eb; --primary-dark: #1e40af; --primary-light: #eff6ff; @@ -26,6 +27,8 @@ --warning-light: #fffbeb; --error: #ef4444; --error-light: #fef2f2; + + /* Tag Colors */ --tag-insumo-bg: #e0f2fe; --tag-insumo-text: #0369a1; --tag-comp-bg: #fef3c7; @@ -36,27 +39,72 @@ --tag-b-text: #854d0e; --tag-c-bg: #fee2e2; --tag-c-text: #991b1b; + + /* Layout */ --nav-height: 64px; --container-max: 1400px; + + /* Spacing Scale */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + --space-3xl: 4rem; + --space-4xl: 6rem; + + /* Typography Scale with clamp() */ + --text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem); + --text-sm: clamp(0.8rem, 0.75rem + 0.25vw, 0.9rem); + --text-base: clamp(0.875rem, 0.85rem + 0.25vw, 1rem); + --text-md: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); + --text-lg: clamp(1.125rem, 1rem + 0.5vw, 1.25rem); + --text-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem); + --text-2xl: clamp(1.5rem, 1.2rem + 1.5vw, 2rem); + --text-3xl: clamp(1.875rem, 1.4rem + 2.5vw, 2.5rem); + --text-4xl: clamp(2.25rem, 1.5rem + 3.75vw, 3.5rem); + + /* Border Radius Scale */ + --radius-xs: 4px; --radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px; --radius-xl: 20px; --radius-2xl: 24px; + --radius-3xl: 32px; --radius-full: 9999px; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + + /* Shadow Scale */ + --shadow-xs: 0 1px 2px rgba(0,0,0,0.04); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07); --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08); --shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1); --shadow-2xl: 0 25px 50px -12px rgba(0,0,0,0.25); - --transition-fast: 150ms cubic-bezier(0.4,0,0.2,1); - --transition-slow: 300ms cubic-bezier(0.4,0,0.2,1); + --shadow-inner: inset 0 2px 4px rgba(0,0,0,0.04); + + /* Transition Timing */ + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-bounce: cubic-bezier(0.175, 0.885, 0.32, 1.275); + --duration-instant: 50ms; + --duration-fast: 150ms; + --duration-normal: 250ms; + --duration-slow: 350ms; + --duration-slower: 500ms; + --transition-fast: var(--duration-fast) var(--ease-out); + --transition-slow: var(--duration-slow) var(--ease-out); + + /* Z-Index Scale */ + --z-base: 0; + --z-dropdown: 100; --z-sticky: 200; - --z-dropdown: 300; - --z-overlay: 400; - --z-modal: 500; - --z-toast: 600; - --z-skip: 700; + --z-overlay: 300; + --z-modal: 400; + --z-toast: 500; + --z-skip: 600; } [data-theme="dark"] { @@ -89,7 +137,8 @@ --tag-b-text: #fde047; --tag-c-bg: #7f1d1d; --tag-c-text: #fca5a5; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-xs: 0 1px 2px rgba(0,0,0,0.2); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); --shadow-md: 0 4px 6px rgba(0,0,0,0.4); --shadow-lg: 0 10px 15px rgba(0,0,0,0.4); --shadow-xl: 0 20px 25px rgba(0,0,0,0.5); diff --git a/demo/css/03-navbar.css b/demo/css/03-navbar.css index 226ad21..d6b0f23 100644 --- a/demo/css/03-navbar.css +++ b/demo/css/03-navbar.css @@ -37,7 +37,7 @@ .logo span { color: var(--primary); } .badge { - font-size: 0.55rem; + font-size: var(--text-xs); font-weight: 800; padding: 0.2rem 0.5rem; border-radius: var(--radius-full); @@ -71,13 +71,16 @@ text-decoration: none; color: var(--secondary); font-weight: 600; - font-size: clamp(0.7rem, 1.5vw, 0.85rem); + font-size: clamp(0.8rem, 1.5vw, 0.875rem); padding: 0.4rem 0.6rem; border-radius: var(--radius-md); transition: all var(--transition-fast); white-space: nowrap; flex-shrink: 0; scroll-snap-align: start; + min-height: 44px; + display: inline-flex; + align-items: center; } .nav-links a:hover, .nav-links a:focus-visible { @@ -104,6 +107,8 @@ justify-content: center; transition: all var(--transition-fast); flex-shrink: 0; + min-height: 44px; + min-width: 44px; } .btn-icon:hover { background: var(--bg-hover); @@ -124,9 +129,6 @@ body.menu-open .icon-menu { display: none; } /* Hamburger hidden by default, shown via media query */ .btn-hamburger { display: none; } -/* Hamburger hidden by default, shown via media query */ -.btn-hamburger { display: none; } - /* Mobile Menu */ .mobile-menu { position: absolute; @@ -175,8 +177,4 @@ body.menu-open .mobile-menu { .mobile-menu { display: none !important; } .btn-hamburger { display: none; } } -@media (min-width: 1024px) { - .mobile-menu { display: none !important; } - .btn-hamburger { display: none; } -} diff --git a/demo/css/04-hero.css b/demo/css/04-hero.css index fb0db73..97769a8 100644 --- a/demo/css/04-hero.css +++ b/demo/css/04-hero.css @@ -10,11 +10,21 @@ content: ''; position: absolute; inset: 0; - background: + background: radial-gradient(circle at 20% 50%, var(--primary-glow) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(16,185,129,0.08) 0%, transparent 40%); pointer-events: none; } +.hero::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--primary), transparent); + opacity: 0.3; +} .hero-content { max-width: var(--container-max); @@ -38,12 +48,12 @@ box-shadow: var(--shadow-sm); } .pulse-dot { - width: 8px; - height: 8px; + width: 0.5rem; + height: 0.5rem; background: var(--success); border-radius: 50%; animation: pulse 2s infinite; - box-shadow: 0 0 8px var(--success); + box-shadow: 0 0 0.5rem var(--success); } @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } @@ -56,6 +66,12 @@ margin-bottom: 1.25rem; font-weight: 900; letter-spacing: -0.04em; + animation: heroFadeIn 0.8s ease-out; + text-wrap: balance; +} +@keyframes heroFadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } } .highlight { color: var(--primary); @@ -72,6 +88,10 @@ background: var(--primary); opacity: 0.15; border-radius: var(--radius-full); + transition: opacity var(--transition-fast); +} +.hero h1:hover .highlight::after { + opacity: 0.3; } .hero-description { @@ -82,6 +102,7 @@ margin-left: auto; margin-right: auto; line-height: 1.7; + text-wrap: pretty; } .hero-tech-stack { @@ -94,7 +115,7 @@ .tech-badge { background: var(--bg-card); color: var(--text-secondary); - font-size: 0.7rem; + font-size: var(--text-sm); font-weight: 700; padding: 0.4rem 0.8rem; border-radius: var(--radius-full); @@ -118,6 +139,11 @@ box-shadow: var(--shadow-xl); border: 1px solid var(--border); margin-bottom: 2.5rem; + animation: statsSlideUp 0.6s ease-out 0.2s both; +} +@keyframes statsSlideUp { + from { opacity: 0; transform: translateY(30px); } + to { opacity: 1; transform: translateY(0); } } .stat-card { display: flex; @@ -125,10 +151,33 @@ align-items: center; padding: 1rem 0.5rem; border-radius: var(--radius-lg); - transition: all var(--transition-fast); + transition: all var(--transition-slow); + position: relative; + overflow: hidden; +} +.stat-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, var(--primary-glow), transparent); + opacity: 0; + transition: opacity var(--transition-fast); +} +.stat-card:hover::before { + opacity: 1; +} +.stat-card:hover { + background: var(--primary-light); + transform: translateY(-2px); +} +.stat-icon { + font-size: 1.5rem; + margin-bottom: 0.5rem; + transition: transform var(--transition-fast); +} +.stat-card:hover .stat-icon { + transform: scale(1.15); } -.stat-card:hover { background: var(--primary-light); } -.stat-icon { font-size: 1.5rem; margin-bottom: 0.5rem; } .stat-val { font-size: clamp(1.5rem, 3vw, 2.5rem); font-weight: 900; @@ -136,9 +185,16 @@ line-height: 1; margin-bottom: 0.35rem; font-variant-numeric: tabular-nums; + transition: color var(--transition-fast); +} +.stat-card:hover .stat-val { + color: var(--primary-dark); +} +[data-theme="dark"] .stat-card:hover .stat-val { + color: #93c5fd; } .stat-lbl { - font-size: clamp(0.55rem, 1vw, 0.7rem); + font-size: var(--text-xs); color: var(--text-muted); text-transform: uppercase; font-weight: 700; @@ -147,7 +203,14 @@ line-height: 1.3; } -.hero-examples { margin-top: 2rem; } +.hero-examples { + margin-top: 2rem; + animation: fadeIn 0.5s ease-out 0.4s both; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} .examples-label { font-size: 0.85rem; color: var(--text-muted); @@ -166,7 +229,7 @@ color: var(--text-secondary); padding: 0.5rem 1rem; border-radius: var(--radius-full); - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: all var(--transition-fast); @@ -174,7 +237,19 @@ align-items: center; gap: 0.4rem; position: relative; + min-height: 44px; z-index: auto; + font-family: inherit; +} +.example-btn::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, var(--primary), var(--success)); + opacity: 0; + z-index: -1; + transition: opacity var(--transition-fast); } .example-btn:hover { background: var(--primary); @@ -183,4 +258,10 @@ transform: translateY(-2px); box-shadow: var(--shadow-md); } +.example-btn:hover::before { + opacity: 0.3; +} +.example-btn:active { + transform: translateY(0); +} diff --git a/demo/css/05-search.css b/demo/css/05-search.css index 0e4c9d5..95abf69 100644 --- a/demo/css/05-search.css +++ b/demo/css/05-search.css @@ -1,7 +1,7 @@ /* ========== SEARCH BOX ========== */ .search-box, .abc-input-box, .compare-input-box { background: var(--bg-card); - padding: 2.5rem; + padding: clamp(1.5rem, 4vw, 2.5rem); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); margin-bottom: 3rem; @@ -23,7 +23,7 @@ .input-wrapper { flex: 1; - min-width: 250px; + min-width: 200px; position: relative; display: flex; align-items: center; @@ -47,7 +47,6 @@ background: var(--bg-input); color: var(--text-main); font-family: inherit; - pointer-events: auto !important; /* Force clickability */ position: relative; z-index: 40; } @@ -57,7 +56,8 @@ background: var(--bg-card); } -button, .search-bar-row button { +.search-bar-row button[type="submit"], +.search-bar-row button:not([type]) { background: var(--primary); color: white; border: none; @@ -73,16 +73,55 @@ button, .search-bar-row button { gap: 0.75rem; position: relative; z-index: 40; + font-family: inherit; + min-height: 44px; } -button:hover { +.search-bar-row button[type="submit"]:hover, +.search-bar-row button:not([type]):hover { background: var(--primary-dark); transform: translateY(-1px); box-shadow: var(--shadow-md); } -button:active { transform: translateY(0); } +.search-bar-row button[type="submit"]:active, +.search-bar-row button:not([type]):active { + transform: translateY(0); +} .search-filters { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 1.5rem; } + +.search-type-toggle { + display: flex; + background: var(--bg-alt); + padding: 0.25rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.btn-search-type { + flex: 1; + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + background: transparent; + color: var(--text-muted); + transition: all var(--transition-fast); + font-family: inherit; + min-height: 44px; +} + +.btn-search-type.active { + background: var(--bg-card); + color: var(--primary); + box-shadow: var(--shadow-sm); +} + +.btn-search-type:hover:not(.active) { + color: var(--text-main); +} diff --git a/demo/css/06-results.css b/demo/css/06-results.css index 84b1ce7..0d1667e 100644 --- a/demo/css/06-results.css +++ b/demo/css/06-results.css @@ -10,7 +10,12 @@ background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border); - transition: background var(--transition-slow), border-color var(--transition-slow); + transition: all var(--transition-slow); + animation: slideIn 0.3s ease-out; +} +@keyframes slideIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } } .results-toolbar { display: flex; @@ -35,13 +40,14 @@ padding: 0.4rem 2rem 0.4rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius-sm); - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 600; background-color: var(--bg-input); color: var(--text-main); cursor: pointer; font-family: inherit; transition: all var(--transition-fast); + min-height: 44px; } .sort-select:focus { border-color: var(--primary); @@ -68,6 +74,8 @@ position: relative; z-index: auto; transition: all var(--transition-fast); + min-height: 44px; + min-width: 44px; } .btn-toggle.active { background: var(--bg-card); @@ -90,7 +98,7 @@ color: var(--text-secondary); padding: 0.5rem 0.9rem; border-radius: var(--radius-sm); - font-size: 0.8rem; + font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: all var(--transition-fast); @@ -100,6 +108,7 @@ font-family: inherit; position: relative; z-index: auto; + min-height: 44px; } .btn-export:hover { background: var(--primary); @@ -132,26 +141,49 @@ display: flex; flex-direction: column; animation: cardAppear 0.4s ease-out; + overflow: hidden; +} +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 0.1875rem; + background: linear-gradient(90deg, var(--primary), var(--success)); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-slow); +} +.card:hover::before { + transform: scaleX(1); } @keyframes cardAppear { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -.card:hover { - transform: translateY(-3px); - box-shadow: var(--shadow-xl); - border-color: var(--primary); +.card:active { + transform: translateY(-1px); +} + +@media (hover: hover) { + .card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-xl); + border-color: var(--primary); + } } -.card:active { transform: translateY(-1px); } .list-view .card { flex-direction: row; align-items: center; - gap: 1.5rem; + gap: 1rem; padding: 1rem 1.5rem; + flex-wrap: wrap; } .list-view .card h3 { - flex: 1; + flex: 1 1 200px; + min-width: 0; margin-bottom: 0; font-size: 0.95rem; white-space: nowrap; @@ -170,7 +202,7 @@ } .type-tag { - font-size: 0.6rem; + font-size: var(--text-xs); font-weight: 800; padding: 0.3rem 0.6rem; border-radius: 6px; @@ -178,6 +210,10 @@ text-transform: uppercase; letter-spacing: 0.05em; display: inline-block; + transition: all var(--transition-fast); +} +.card:hover .type-tag { + transform: scale(1.05); } .tag-insumo { background: var(--tag-insumo-bg); color: var(--tag-insumo-text); } .tag-comp { background: var(--tag-comp-bg); color: var(--tag-comp-text); } @@ -218,30 +254,37 @@ } /* ========== SKELETON LOADER ========== */ -.skeleton-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1.25rem; +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } } -.skeleton-card { - background: var(--bg-card); - border-radius: var(--radius-lg); - border: 1px solid var(--border); - padding: 1.5rem; - height: 180px; + +/* Single shimmer utility — apply to any skeleton block */ +.shimmer { position: relative; overflow: hidden; } -.skeleton-card::after { +.shimmer::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); animation: shimmer 1.5s infinite; } -@keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} +.skeleton-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: 1.5rem; + height: auto; + aspect-ratio: 16 / 10; + min-height: 120px; } .skeleton-abc, .skeleton-compare { @@ -253,46 +296,22 @@ background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border); - height: 350px; - position: relative; - overflow: hidden; -} -.skeleton-chart::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); - animation: shimmer 1.5s infinite; + height: auto; + aspect-ratio: 16 / 9; + min-height: 200px; } .skeleton-table { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border); - height: 200px; - position: relative; - overflow: hidden; -} -.skeleton-table::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); - animation: shimmer 1.5s infinite; + min-height: 150px; + height: auto; } .skeleton-title { background: var(--bg-card); border-radius: var(--radius-md); border: 1px solid var(--border); height: 40px; - position: relative; - overflow: hidden; -} -.skeleton-title::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, var(--bg-hover), transparent); - animation: shimmer 1.5s infinite; } /* ========== LOADER ========== */ diff --git a/demo/css/07-charts.css b/demo/css/07-charts.css index 1e09250..75ec0ce 100644 --- a/demo/css/07-charts.css +++ b/demo/css/07-charts.css @@ -1,7 +1,9 @@ /* ========== CHARTS ========== */ .chart-container { position: relative; - height: 350px; + height: auto; + aspect-ratio: 16 / 9; + min-height: 250px; width: 100%; margin-bottom: 2rem; background: var(--bg-card); @@ -9,10 +11,15 @@ border: 1px solid var(--border); padding: 1.5rem; overflow: hidden; + transition: all var(--transition-slow); +} +.chart-container:hover { + border-color: var(--primary); + box-shadow: var(--shadow-md); } .chart-container canvas { - width: 100% !important; - height: 100% !important; + width: 100%; + height: 100%; } /* ========== ABC RESULTS ========== */ @@ -30,6 +37,11 @@ border-radius: var(--radius-lg); border: 1px solid var(--border); overflow: hidden; + transition: all var(--transition-slow); +} +.abc-table-container:hover { + border-color: var(--primary); + box-shadow: var(--shadow-md); } .table-header { display: flex; @@ -37,6 +49,7 @@ align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); + background: var(--bg-alt); } .table-header h4 { font-size: 1rem; @@ -44,7 +57,7 @@ color: var(--text-main); } .table-badge { - font-size: 0.7rem; + font-size: var(--text-sm); font-weight: 700; padding: 0.25rem 0.6rem; border-radius: var(--radius-full); @@ -55,6 +68,7 @@ .data-table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable both-edges; } .data-table { width: 100%; @@ -65,20 +79,30 @@ background: var(--bg-alt); padding: 1rem 1.25rem; text-align: left; - font-size: 0.7rem; + font-size: var(--text-sm); text-transform: uppercase; font-weight: 800; color: var(--text-muted); border-bottom: 2px solid var(--border); position: sticky; top: 0; + z-index: 1; } .data-table td { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); font-weight: 500; color: var(--text-main); + transition: background var(--transition-fast); +} +.data-table tbody tr { + transition: background var(--transition-fast); } -.data-table tbody tr { transition: background var(--transition-fast); } -.data-table tbody tr:hover { background: var(--primary-light); } +.data-table tbody tr:hover { + background: var(--primary-light); +} +.data-table tbody tr:last-child td { + border-bottom: none; +} + diff --git a/demo/css/08-compare.css b/demo/css/08-compare.css index 7edf8ae..39a87e2 100644 --- a/demo/css/08-compare.css +++ b/demo/css/08-compare.css @@ -5,6 +5,7 @@ font-weight: 800; color: var(--text-main); margin-bottom: 1.5rem; + animation: slideUp 0.5s ease-out; } .compare-stats { display: grid; @@ -22,13 +23,30 @@ flex-direction: column; gap: 0.35rem; transition: all var(--transition-fast); + position: relative; + overflow: hidden; +} +.compare-stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 0.1875rem; + background: linear-gradient(90deg, var(--primary), var(--success)); + transform: scaleX(0); + transition: transform var(--transition-slow); +} +.compare-stat-card:hover::before { + transform: scaleX(1); } .compare-stat-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); + transform: translateY(-2px); } .compare-stat-label { - font-size: 0.65rem; + font-size: var(--text-xs); font-weight: 800; color: var(--text-muted); text-transform: uppercase; @@ -39,11 +57,134 @@ font-weight: 900; color: var(--primary); font-variant-numeric: tabular-nums; + transition: color var(--transition-fast); +} +.compare-stat-card:hover .compare-stat-value { + color: var(--primary-dark); +} +[data-theme="dark"] .compare-stat-card:hover .compare-stat-value { + color: #93c5fd; } .compare-stat-uf { - font-size: 0.7rem; + font-size: var(--text-sm); color: var(--text-muted); font-weight: 600; } -.compare-chart-container { height: 400px; } +.compare-chart-container { + height: auto; + aspect-ratio: 16 / 10; + min-height: 300px; +} + +/* ========== STATE SELECTOR ========== */ +.state-selector { + margin-top: 1.5rem; + animation: fadeIn 0.3s ease-out; +} +.state-selector-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; +} +.state-selector-header label { + font-weight: 600; + font-size: 0.9rem; + color: var(--text-main); +} +.state-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.btn-state-action { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + font-family: inherit; + min-height: 44px; + min-width: 44px; +} +.btn-state-action:hover { + background: var(--primary); + color: white; + border-color: var(--primary); + transform: translateY(-1px); +} +.btn-state-action:active { + transform: translateY(0); +} +.state-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + max-height: clamp(120px, 25vh, 200px); + overflow-y: auto; + padding: 0.75rem; + background: var(--bg-alt); + border-radius: var(--radius-md); + border: 1px solid var(--border); + transition: border-color var(--transition-fast); + scrollbar-gutter: stable both-edges; +} +.state-chips:hover { + border-color: var(--primary); +} +.state-chip { + padding: 0.4rem 0.75rem; + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: 700; + cursor: pointer; + border: 2px solid var(--border); + background: var(--bg-card); + color: var(--text-secondary); + transition: all var(--transition-fast); + font-family: inherit; + user-select: none; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.state-chip:hover { + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); +} +.state-chip:active { + transform: translateY(0); +} +.state-chip.selected { + background: var(--primary); + border-color: var(--primary); + color: white; + box-shadow: 0 2px 8px var(--primary-glow); +} +.state-chip.selected:hover { + background: var(--primary-dark); + border-color: var(--primary-dark); +} +[data-theme="dark"] .state-chip.selected { + background: #3b82f6; + border-color: #3b82f6; +} +[data-theme="dark"] .state-chip.selected:hover { + background: #2563eb; + border-color: #2563eb; +} +.state-selector-footer { + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 500; +} diff --git a/demo/css/09-modal.css b/demo/css/09-modal.css index 00d2e50..2f87e5c 100644 --- a/demo/css/09-modal.css +++ b/demo/css/09-modal.css @@ -2,31 +2,34 @@ .modal-overlay { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.75); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); z-index: var(--z-modal); display: flex; justify-content: center; align-items: center; - padding: 1.5rem; + padding: 1rem; } .modal-container { background: var(--bg-card); - width: 90%; - max-width: 1400px; - height: 90vh; + width: 96%; + max-width: 1100px; + height: auto; + max-height: 92dvh; + max-height: 92vh; border-radius: var(--radius-2xl); display: flex; flex-direction: column; overflow: hidden; box-shadow: var(--shadow-2xl); animation: modalSlideUp 0.3s ease-out; + border: 1px solid var(--border); } .modal-header { - padding: 1.5rem 2.5rem; + padding: 1rem 1.75rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; @@ -36,37 +39,474 @@ } .modal-body { - padding: 2.5rem; + padding: 1.5rem; overflow-y: auto; flex: 1; -webkit-overflow-scrolling: touch; } +/* Modal Grid Layout - Hierarchical */ .modal-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); - gap: 2.5rem; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto auto; + gap: 1.25rem; +} + +/* History chart spans full width */ +.modal-card.history-card { + grid-column: 1 / -1; + width: 100%; +} + +/* Man-hours is smaller, optimization is larger */ +.modal-card.manhours-card { + grid-column: 1; +} +.modal-card.optimization-card { + grid-column: 2; +} + +/* BOM spans full width */ +.modal-card.bom-card-section { + grid-column: 1 / -1; } .modal-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-xl); - padding: 2rem; + padding: 1.25rem; display: flex; flex-direction: column; + transition: border-color var(--transition-fast); + min-width: 0; + overflow: hidden; +} +.modal-card:hover { + border-color: var(--primary); +} +.modal-card h4 { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-main); + margin-bottom: 0.75rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--border); } +/* BOM Section Header with Toggle */ +.bom-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--border); +} +.bom-section-header h4 { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} +.bom-view-toggle { + display: flex; + background: var(--bg-alt); + padding: 0.15rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} +.bom-view-toggle .btn-toggle { + padding: 0.3rem 0.5rem; +} + +/* Chart Container - Responsive Height */ .modal-chart-container { position: relative; - height: 350px !important; /* Fixed height to prevent infinite growth */ + height: clamp(220px, 28vh, 380px); width: 100%; flex-shrink: 0; + overflow: hidden; } .modal-description { - font-size: clamp(1.4rem, 2.5vw, 2rem); + font-size: clamp(1.1rem, 2vw, 1.5rem); + font-weight: 800; + color: var(--text-main); + margin: 0.5rem 0; + line-height: 1.3; +} + +/* Modal Item Info */ +.item-info-hero { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} +.item-info-top { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.modal-code-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.2rem 0.5rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); +} +.modal-meta { + font-size: 0.8rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +.modal-meta strong { + color: var(--text-main); + font-weight: 600; +} +.modal-export { + margin-top: 0.25rem; +} + +/* Modal Price Display */ +.modal-price-display { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0.25rem 0; +} +.modal-price-value { + font-size: clamp(1.6rem, 3vw, 2.2rem); + font-weight: 900; + color: var(--primary); + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; +} +.modal-price-unit { + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 500; +} + +/* Modal Stats Row */ +.modal-stats-row { + display: flex; + gap: 0.75rem; + margin: 0.75rem 0 0.25rem; + flex-wrap: wrap; +} +.modal-stat-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.6rem 0.85rem; + background: var(--bg-alt); + border-radius: var(--radius-md); + border: 1px solid var(--border); + min-width: 100px; + flex: 1; +} +.modal-stat-label { + font-size: var(--text-xs); + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.modal-stat-value { + font-size: 1rem; + font-weight: 800; + color: var(--text-main); + font-variant-numeric: tabular-nums; +} + +/* BOM Cards View */ +.bom-grid { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: clamp(200px, 40vh, 400px); + overflow-y: auto; + overflow-x: hidden; + padding-right: 0.25rem; + scrollbar-gutter: stable both-edges; +} +.bom-card { + background: var(--bg-alt); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + border: 1px solid var(--border); + transition: all var(--transition-fast); + min-width: 0; + width: 100%; +} +.bom-card:hover { + background: var(--primary-light); + border-color: var(--primary); +} +.bom-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; + padding-left: var(--bom-indent, 0); +} +.bom-card-desc { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-main); + margin: 0 0 0.5rem; + line-height: 1.3; + display: block; + max-height: calc(1.3em * 5); + overflow-y: auto; + word-break: break-word; + padding-left: var(--bom-indent, 0); +} +.bom-level-badge { + width: 1.375rem; + height: 1.375rem; + border-radius: 50%; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-xs); font-weight: 800; + flex-shrink: 0; +} +.bom-card-code { + font-family: 'JetBrains Mono', monospace; + font-size: var(--text-sm); + color: var(--text-muted); + font-weight: 600; + word-break: break-all; + overflow-wrap: break-word; + min-width: 0; +} +.bom-card-footer { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.75rem; + color: var(--text-muted); +} +.bom-card-coef { + font-family: 'JetBrains Mono', monospace; + font-size: var(--text-sm); +} +.bom-card-impact { + font-weight: 700; + color: var(--primary); + font-variant-numeric: tabular-nums; +} +.bom-card-pct { + font-size: var(--text-sm); + color: var(--text-muted); +} + +/* BOM Table Wrapper */ +.bom-table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: var(--radius-md); + border: 1px solid var(--border); + scrollbar-gutter: stable both-edges; + position: relative; +} +.bom-table-wrapper::after { + content: '\2192'; + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + opacity: 0; + font-size: 1.5rem; + color: var(--text-muted); + transition: opacity var(--transition-fast); +} +.bom-table-wrapper.is-scrollable::after { + opacity: 0.6; +} +.bom-table-wrapper.is-scrolled::after { + opacity: 0; +} +.bom-table-wrapper .data-table { + width: 100%; + border-collapse: collapse; + min-width: 600px; +} +.bom-table-wrapper .data-table th { + background: var(--bg-alt); + padding: 0.65rem 0.85rem; + text-align: left; + font-size: var(--text-xs); + text-transform: uppercase; + font-weight: 800; + color: var(--text-muted); + border-bottom: 2px solid var(--border); + position: sticky; + top: 0; + z-index: 1; + white-space: nowrap; +} +.bom-table-wrapper .data-table td { + padding: 0.65rem 0.85rem; + border-bottom: 1px solid var(--border); + font-weight: 500; color: var(--text-main); - margin: 1rem 0; + vertical-align: middle; + white-space: nowrap; + font-size: var(--text-sm); +} +.bom-table-wrapper .data-table td.desc-col { + white-space: normal; + word-break: break-word; + max-width: 280px; + padding-left: var(--bom-indent, 0.85rem); +} +.bom-table-wrapper .data-table tbody tr { + transition: background var(--transition-fast); +} +.bom-table-wrapper .data-table tbody tr:hover { + background: var(--primary-light); } +.bom-table-wrapper .data-table tbody tr:last-child td { + border-bottom: none; +} + +/* Man Hours */ +.man-hours-display { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--bg-alt); + border-radius: var(--radius-md); + border: 1px solid var(--border); +} +.man-hours-icon { + font-size: 2rem; +} +.man-hours-value { + font-size: 1.5rem; + font-weight: 900; + color: var(--primary); + font-variant-numeric: tabular-nums; + line-height: 1; +} +.man-hours-label { + font-size: var(--text-sm); + color: var(--text-muted); + font-weight: 600; + margin-top: 0.25rem; +} + +/* Optimization Candidates */ +#optimizationContainer { + margin-top: 0.5rem; +} +#optimizationContainer .opt-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border); +} +#optimizationContainer .opt-item:last-child { + border-bottom: none; +} +#optimizationContainer .opt-rank { + width: 1.625rem; + height: 1.625rem; + border-radius: 50%; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-sm); + font-weight: 800; + flex-shrink: 0; +} +#optimizationContainer .opt-rank.rank-1 { background: #dc2626; } +#optimizationContainer .opt-rank.rank-2 { background: #ea580c; } +#optimizationContainer .opt-rank.rank-3 { background: #d97706; } +#optimizationContainer .opt-info { + flex: 1; + min-width: 0; +} +#optimizationContainer .opt-desc { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#optimizationContainer .opt-meta { + font-size: var(--text-xs); + color: var(--text-muted); +} +#optimizationContainer .opt-impact { + font-size: 0.9rem; + font-weight: 800; + color: var(--primary); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* Modal Animation */ +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Modal Close Button */ +.modal-close { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + padding: 0.4rem; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + min-width: 44px; +} +.modal-close:hover { + background: var(--bg-hover); + color: var(--text-main); + border-color: var(--primary); +} + +/* Hidden States */ +#bomSection.hidden { display: none; } +#manHoursSection.hidden { display: none; } +#optimizationSection.hidden { display: none; } +.bom-view-toggle.hidden { display: none; } +#bomGrid.hidden { display: none; } +#bomTableWrapper.hidden { display: none; } diff --git a/demo/css/10-footer.css b/demo/css/10-footer.css index 280cb2b..b33e1c4 100644 --- a/demo/css/10-footer.css +++ b/demo/css/10-footer.css @@ -1,13 +1,23 @@ /* ========== FOOTER ========== */ footer { - background: #0f172a; - color: white; - padding: 5rem 2rem; + background: linear-gradient(180deg, var(--bg-alt) 0%, var(--bg-main) 100%); + color: var(--text-main); + padding: clamp(3rem, 8vw, 5rem) clamp(1rem, 4vw, 2rem); border-top: 4px solid var(--primary); - margin-top: 6rem; + margin-top: clamp(3rem, 8vw, 6rem); position: relative; z-index: 10; } +footer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--primary), transparent); + opacity: 0.5; +} .footer-content { max-width: var(--container-max); @@ -26,16 +36,18 @@ footer { } .footer-brand h2 { - font-size: 2rem; + font-size: clamp(1.5rem, 3vw, 2rem); margin-bottom: 1rem; font-weight: 900; + letter-spacing: -0.03em; + color: var(--text-main); } .footer-brand h2 span { color: var(--primary); } .footer-brand p { - color: #94a3b8; + color: var(--text-secondary); font-size: 1rem; - max-width: 400px; + max-width: min(400px, 90%); line-height: 1.6; } @@ -48,38 +60,67 @@ footer { font-size: 0.9rem; font-weight: 800; margin-bottom: 1.5rem; - color: white; + color: var(--text-main); text-transform: uppercase; letter-spacing: 0.05em; + position: relative; +} +.footer-links h4::after, .footer-meta h4::after { + content: ''; + position: absolute; + bottom: -0.5rem; + left: 0; + width: 1.875rem; + height: 2px; + background: var(--primary); + border-radius: 2px; } .footer-link { display: block; - color: #94a3b8; + color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; padding: 0.5rem 0; - transition: color var(--transition-fast); + transition: all var(--transition-fast); + position: relative; + padding-left: 0; +} +.footer-link::before { + content: ''; + position: absolute; + left: 0; + bottom: 0.5rem; + width: 0; + height: 1px; + background: var(--primary); + transition: width var(--transition-fast); +} +.footer-link:hover { + color: var(--primary); + padding-left: 0.5rem; +} +.footer-link:hover::before { + width: 100%; } -.footer-link:hover { color: var(--primary); } .status-badge { display: inline-flex; align-items: center; gap: 0.5rem; - background: rgba(16, 185, 129, 0.1); + background: color-mix(in srgb, var(--success) 10%, transparent); color: var(--success); padding: 0.6rem 1.2rem; border-radius: var(--radius-full); font-weight: 700; - border: 1px solid rgba(16, 185, 129, 0.2); + border: 1px solid color-mix(in srgb, var(--success) 20%, transparent); font-size: 0.85rem; margin-bottom: 1rem; } .status-dot { - width: 10px; - height: 10px; + width: 0.625rem; + height: 0.625rem; background: var(--success); border-radius: 50%; box-shadow: 0 0 12px var(--success); @@ -88,7 +129,7 @@ footer { .footer-tier-info { font-size: 0.85rem; - color: #64748b; + color: var(--text-muted); margin-top: 1rem; } @@ -100,13 +141,13 @@ footer { } .footer-tech-badge { - font-size: 0.7rem; + font-size: var(--text-sm); font-weight: 700; padding: 0.3rem 0.75rem; border-radius: var(--radius-full); - background: #1e293b; - color: #94a3b8; - border: 1px solid #334155; + background: var(--bg-card); + color: var(--text-secondary); + border: 1px solid var(--border); } @media (max-width: 768px) { diff --git a/demo/css/11-toast.css b/demo/css/11-toast.css index 875b076..4cc45a8 100644 --- a/demo/css/11-toast.css +++ b/demo/css/11-toast.css @@ -1,7 +1,8 @@ /* ========== TOAST ========== */ .toast { position: fixed; - bottom: 2rem; + bottom: clamp(1rem, 3vh, 2rem); + bottom: calc(env(safe-area-inset-bottom, 2rem) + 0.5rem); left: 50%; transform: translate(-50%, 150%); background: var(--text-main); @@ -13,9 +14,29 @@ box-shadow: var(--shadow-xl); transition: transform 0.4s cubic-bezier(0.175,0.885,0.32,1.275); z-index: var(--z-toast); - pointer-events: none; + pointer-events: auto; + cursor: pointer; max-width: 90vw; text-align: center; + backdrop-filter: blur(8px); +} +.toast.show { + transform: translate(-50%, 0); +} +.toast-success { + background: var(--success); + color: white; +} +.toast-error { + background: var(--error); + color: white; +} +.toast-warning { + background: var(--warning); + color: white; +} +.toast-info { + background: var(--primary); + color: white; } -.toast.show { transform: translate(-50%, 0); } diff --git a/demo/css/12-utilities.css b/demo/css/12-utilities.css index 4c95853..67cf1f0 100644 --- a/demo/css/12-utilities.css +++ b/demo/css/12-utilities.css @@ -1,3 +1,195 @@ -/* ========== UTILITIES ========== */ +/* ========== UTILITIES & BASE COMPONENTS ========== */ + +/* Visibility */ .hidden { display: none !important; } +.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; +} + +/* Sections */ +.tool-section { + padding: clamp(3rem, 6vw, 5rem) clamp(1rem, 3vw, 1.5rem); + max-width: var(--container-max); + margin: 0 auto; +} +.tool-section.alt-bg { + background: var(--bg-alt); + border-radius: var(--radius-2xl); + margin: 0 auto; + padding: clamp(3rem, 6vw, 5rem) clamp(1rem, 4vw, 2rem); + transition: background var(--transition-slow); +} + +/* Section Headers */ +.section-header { + text-align: center; + max-width: min(800px, 90%); + margin: 0 auto 3rem; +} +.section-title { + font-size: clamp(1.5rem, 3.5vw, 2.5rem); + font-weight: 900; + color: var(--text-main); + letter-spacing: -0.03em; + line-height: 1.15; + margin-bottom: 0.75rem; + text-wrap: balance; +} +.section-subtitle { + font-size: clamp(0.9rem, 1.5vw, 1.05rem); + color: var(--text-muted); + line-height: 1.7; + margin-bottom: 1.5rem; + text-wrap: pretty; +} + +/* API Endpoint Display */ +.api-endpoint { + display: inline-flex; + align-items: center; + gap: 0.75rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.6rem 1rem; + font-size: var(--text-sm); + transition: all var(--transition-fast); + max-width: 100%; + min-width: 0; + flex-wrap: wrap; +} +.api-endpoint code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--primary); + font-weight: 500; + white-space: normal; + word-break: break-all; + overflow-wrap: break-word; + max-width: 100%; + flex: 1 1 auto; + min-width: 0; + display: inline; +} +[data-theme="dark"] .api-endpoint code { + color: #93c5fd; +} + +/* Copy cURL Button */ +.btn-copy-curl { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.35rem 0.6rem; + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + font-family: inherit; + white-space: nowrap; + flex-shrink: 0; + min-height: 44px; +} +.btn-copy-curl:hover { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +/* Primary Outline Button */ +.btn-primary-outline { + background: transparent; + border: 2px solid var(--primary); + color: var(--primary); +} +.btn-primary-outline:hover { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +/* Filter Columns */ +.filter-col { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.filter-col label { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.filter-col select { + padding: 0.75rem 2.5rem 0.75rem 1rem; + border: 2px solid var(--border); + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 500; + background-color: var(--bg-input); + color: var(--text-main); + cursor: pointer; + font-family: inherit; + transition: all var(--transition-fast); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; +} +.filter-col select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); + outline: none; +} +.filter-col select:hover { + border-color: var(--primary); +} + +/* Text Alignment */ +.text-left { text-align: left; } +.text-center { text-align: center; } +.text-right { text-align: right; } + +/* Empty State */ +.empty-state { + text-align: center; + padding: clamp(2rem, 5vw, 3rem) clamp(1rem, 3vw, 2rem); + color: var(--text-muted); + font-size: 0.95rem; + font-weight: 500; +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; +} +::-webkit-scrollbar-track { + background: var(--bg-alt); + border-radius: 4px; +} +::-webkit-scrollbar-thumb { + background: var(--secondary-light); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--primary); +} +/* Selection */ +::selection { + background: var(--primary); + color: white; +} diff --git a/demo/css/13-responsive.css b/demo/css/13-responsive.css index 98b6c21..7d28dc3 100644 --- a/demo/css/13-responsive.css +++ b/demo/css/13-responsive.css @@ -8,47 +8,56 @@ .badge { display: none; } .hero { padding: calc(var(--nav-height) + 1.5rem) 0.75rem 2rem; } .hero h1 { font-size: 1.5rem; } - .hero-description { font-size: 0.85rem; } + .hero-description { font-size: var(--text-sm); } .hero-tech-stack { gap: 0.3rem; margin-bottom: 1.5rem; } - .tech-badge { font-size: 0.6rem; padding: 0.3rem 0.5rem; } + .tech-badge { font-size: var(--text-sm); padding: 0.3rem 0.5rem; } .hero-stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 1rem; } .stat-icon { font-size: 1.2rem; } .stat-val { font-size: 1.25rem; } - .stat-lbl { font-size: 0.5rem; } + .stat-lbl { font-size: var(--text-xs); } .examples-grid { flex-direction: column; align-items: stretch; } - .example-btn { justify-content: center; font-size: 0.75rem; } + .example-btn { justify-content: center; font-size: var(--text-sm); } .tool-section { padding: 2rem 0.75rem; } + .tool-section.alt-bg { margin: 0 0.5rem; padding: 2rem 1rem; } .section-header { margin-bottom: 1.5rem; } - .section-title { font-size: 1.25rem; } - .section-subtitle { font-size: 0.8rem; } - .api-endpoint { font-size: 0.6rem; padding: 0.4rem 0.6rem; } + .section-title { font-size: var(--text-xl); } + .section-subtitle { font-size: var(--text-sm); } .search-box, .abc-input-box, .compare-input-box { padding: 1rem; } .search-bar-row { flex-direction: column; gap: 0.5rem; } .input-wrapper { min-width: 100%; } - .search-bar-row button { width: 100%; } + .search-bar-row button[type="submit"], + .search-bar-row button:not([type]) { width: 100%; } .search-filters { grid-template-columns: 1fr; gap: 0.75rem; } + .search-type-toggle { flex-direction: row; } .state-selector { padding: 0.75rem; } .state-chips { max-height: 120px; } - .state-chip { font-size: 0.65rem; padding: 0.3rem 0.5rem; } + .state-chip { font-size: var(--text-sm); padding: 0.4rem 0.6rem; min-height: 44px; } .results-actions { padding: 0.75rem; flex-direction: column; align-items: stretch; } .results-toolbar { flex-direction: column; align-items: flex-start; gap: 0.75rem; } .toolbar-controls { width: 100%; justify-content: space-between; } .export-group { width: 100%; justify-content: center; } .results-grid.grid-view { grid-template-columns: 1fr; gap: 0.75rem; } .card { padding: 1rem; } - .card h3 { font-size: 0.85rem; margin-bottom: 0.75rem; } - .chart-container { height: 200px; padding: 0.75rem; } + .card h3 { font-size: var(--text-base); margin-bottom: 0.75rem; } + .chart-container { height: auto; aspect-ratio: 16 / 9; min-height: 200px; padding: 0.75rem; } .compare-stats { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; } .compare-stat-card { padding: 0.75rem; } - .compare-chart-container { height: 250px; } - .modal-container { width: 100%; height: 100vh; max-height: 100vh; border-radius: 0; } - .modal-header { padding: 1rem; } + .compare-chart-container { height: auto; aspect-ratio: 16 / 10; min-height: 250px; } + .modal-container { width: 100%; height: auto; max-height: 100dvh; max-height: 100vh; border-radius: 0; } + .modal-header { padding: 0.75rem 1rem; } .modal-body { padding: 1rem; } .modal-grid { grid-template-columns: 1fr; gap: 1rem; } .modal-card { padding: 1rem; } - .modal-chart-container { height: 180px; } + .modal-card.history-card { grid-column: 1; } + .modal-card.manhours-card { grid-column: 1; } + .modal-card.optimization-card { grid-column: 1; } + .modal-card.bom-card-section { grid-column: 1; } + .modal-chart-container { height: clamp(180px, 25vh, 250px); } + .modal-stats-row { flex-direction: column; } + .modal-stat-item { min-width: 100%; } + .bom-grid { max-height: 250px; } .footer-content { grid-template-columns: 1fr; gap: 1.5rem; } - .toast { font-size: 0.8rem; padding: 0.7rem 1.25rem; bottom: 1rem; } + .toast { font-size: var(--text-sm); padding: 0.7rem 1.25rem; bottom: 1rem; } } /* Small Phones (375px - 479px) */ @@ -57,6 +66,10 @@ .results-grid.grid-view { grid-template-columns: 1fr; } .compare-stats { grid-template-columns: repeat(2, 1fr); } .modal-grid { grid-template-columns: 1fr; } + .modal-card.history-card { grid-column: 1; } + .modal-card.manhours-card { grid-column: 1; } + .modal-card.optimization-card { grid-column: 1; } + .modal-stats-row { flex-direction: column; } } /* Large Phones (480px - 767px) */ @@ -65,68 +78,79 @@ .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } .compare-stats { grid-template-columns: repeat(4, 1fr); } .modal-grid { grid-template-columns: 1fr; } + .modal-card.history-card { grid-column: 1; } + .modal-card.manhours-card { grid-column: 1; } + .modal-card.optimization-card { grid-column: 1; } } /* Tablets (768px - 1023px) */ @media (min-width: 768px) and (max-width: 1023px) { .hero-stats-grid { grid-template-columns: repeat(4, 1fr); } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } - .modal-container { max-width: 95vw; height: 90vh; } + .modal-container { max-width: 95vw; height: 92vh; } .modal-grid { grid-template-columns: 1fr; } + .modal-card.history-card { grid-column: 1; } + .modal-card.manhours-card { grid-column: 1; } + .modal-card.optimization-card { grid-column: 1; } + .modal-card.bom-card-section { grid-column: 1; } .footer-content { grid-template-columns: repeat(2, 1fr); } + .tool-section.alt-bg { margin: 0 1rem; padding: 4rem 1.5rem; } } /* Laptops (1024px - 1439px) */ @media (min-width: 1024px) { .hero-stats-grid { grid-template-columns: repeat(4, 1fr); } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } - .modal-grid { grid-template-columns: repeat(2, 1fr); } + .modal-grid { grid-template-columns: 1fr 1fr; } + .modal-card.history-card { grid-column: 1 / -1; } + .modal-card.manhours-card { grid-column: 1; } + .modal-card.optimization-card { grid-column: 2; } + .modal-card.bom-card-section { grid-column: 1 / -1; } .footer-content { grid-template-columns: 2fr 1fr 1.5fr; } } /* Large Desktops (1440px+) */ @media (min-width: 1440px) { :root { --container-max: 1600px; } - html { font-size: 17px; } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } - .chart-container { height: 400px; } - .compare-chart-container { height: 450px; } + .chart-container { height: auto; aspect-ratio: 16 / 9; min-height: 400px; } + .compare-chart-container { height: auto; aspect-ratio: 16 / 10; min-height: 450px; } } /* Full HD (1920px+) */ @media (min-width: 1920px) { :root { --container-max: 1800px; } - html { font-size: 18px; } .hero { padding: calc(var(--nav-height) + 5rem) 2rem 6rem; } .tool-section { padding: 6rem 2rem; } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); } - .chart-container { height: 450px; } - .compare-chart-container { height: 500px; } - .modal-container { max-width: 1600px; } + .chart-container { min-height: 450px; } + .compare-chart-container { min-height: 500px; } + .modal-container { max-width: 1300px; } + .modal-chart-container { height: clamp(280px, 32vh, 420px); } } /* 4K (2560px+) */ @media (min-width: 2560px) { :root { --container-max: 2200px; } - html { font-size: 20px; } .hero { padding: calc(var(--nav-height) + 6rem) 3rem 8rem; } .tool-section { padding: 8rem 3rem; } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); } - .chart-container { height: 500px; } - .compare-chart-container { height: 550px; } - .modal-container { max-width: 2000px; } + .chart-container { min-height: 500px; } + .compare-chart-container { min-height: 550px; } + .modal-container { max-width: 1600px; } + .modal-chart-container { height: clamp(320px, 35vh, 480px); } } /* 8K (3840px+) */ @media (min-width: 3840px) { :root { --container-max: 3200px; } - html { font-size: 24px; } .hero { padding: calc(var(--nav-height) + 8rem) 4rem 10rem; } .tool-section { padding: 10rem 4rem; } .results-grid.grid-view { grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); } - .chart-container { height: 600px; } - .compare-chart-container { height: 700px; } - .modal-container { max-width: 2800px; } + .chart-container { min-height: 600px; } + .compare-chart-container { min-height: 700px; } + .modal-container { max-width: 2200px; } + .modal-chart-container { height: clamp(380px, 38vh, 550px); } .state-chips { max-height: 300px; } } @@ -150,6 +174,11 @@ .card { break-inside: avoid; box-shadow: none; border: 1px solid #ccc; } .modal-overlay { position: static; background: none; backdrop-filter: none; } .modal-container { width: 100%; height: auto; box-shadow: none; } + .results-grid { display: block; } + .results-grid .card { margin-bottom: 1rem; page-break-inside: avoid; } + .chart-container { break-inside: avoid; } + a[href]::after { content: " (" attr(href) ")"; font-size: 0.8em; } + .api-endpoint code { white-space: normal; } } /* Reduced Motion */ @@ -166,5 +195,29 @@ @media (prefers-contrast: high) { :root { --border: #000; --text-muted: #333; } [data-theme="dark"] { --border: #fff; --text-muted: #ccc; } - .card, button, .btn-export, .btn-toggle { border-width: 2px; } + .card, .btn-export, .btn-toggle, .btn-copy-curl, .btn-state-action, .btn-search-type { border-width: 2px; } + .state-chip { border-width: 3px; } +} + +/* Windows High Contrast Mode (forced-colors) */ +@media (forced-colors: active) { + .card, .btn-export, .btn-toggle, .state-chip, .btn-state-action, .btn-search-type { + border: 2px solid ButtonText; + } + .badge-free, .status-badge { + border: 2px solid LinkText; + } + .type-tag, .tag-a, .tag-b, .tag-c { + border: 1px solid ButtonText; + } + .modal-overlay { + background: Canvas; + } + .chart-container canvas { + forced-color-adjust: none; + } + .state-chip.selected { + background: Highlight; + color: HighlightText; + } } diff --git a/demo/index.html b/demo/index.html index c8af84e..43706b0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -20,13 +20,13 @@ -