commit fb27cd60c07f963d2a66d256b81b6b9df930c28f Author: root Date: Fri Aug 21 22:57:44 2026 +0000 Versão inicial em produção diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd13edb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +includes/config.php +includes/ambiente.json diff --git a/acoes.php b/acoes.php new file mode 100644 index 0000000..ac58ff7 --- /dev/null +++ b/acoes.php @@ -0,0 +1,495 @@ + 'Ação', + 'etf' => 'ETF', + 'bdr' => 'BDR', + 'fii' => 'FII', +]; + +/** + * Simula, em ordem cronológica, uma lista de operações (compra/venda) de um + * mesmo ativo e devolve o estado final. Regra do PM (preço médio): + * - compra: recalcula a média ponderada e aumenta a quantidade. + * - venda: só reduz a quantidade; o PM das ações que sobram não muda. + * "ok" vem false se em algum ponto a quantidade ficaria negativa (ou seja, + * uma venda maior do que o que havia disponível até aquela data). + * Cada item de $linhas precisa ter: tipo, quantidade, preco, data_operacao, _ordem. + */ +function ao_simular_transacoes(array $linhas) { + usort($linhas, function ($a, $b) { + $an = $a['data_operacao'] === null; + $bn = $b['data_operacao'] === null; + if ($an !== $bn) { + return $an <=> $bn; // datadas primeiro, sem data por último + } + if (!$an && $a['data_operacao'] !== $b['data_operacao']) { + return strcmp($a['data_operacao'], $b['data_operacao']); + } + return $a['_ordem'] <=> $b['_ordem']; + }); + + $qtd = 0; + $pm = 0.0; + $ok = true; + + foreach ($linhas as $l) { + $q = (int) $l['quantidade']; + $p = (float) $l['preco']; + if ($l['tipo'] === 'compra') { + $novoQtd = $qtd + $q; + $pm = $novoQtd > 0 ? (($qtd * $pm) + ($q * $p)) / $novoQtd : 0.0; + $qtd = $novoQtd; + } else { + $qtd -= $q; + if ($qtd < 0) { + $ok = false; + } + } + } + + return ['ok' => $ok, 'qtd' => max(0, $qtd), 'pm' => round($pm, 4)]; +} + +/** Busca as operações de um ativo (de um usuário), no formato usado por ao_simular_transacoes(). */ +function ao_buscar_transacoes_ativo(PDO $pdo, $usuario_id, $ativo, $ignorar_id = null) { + $sql = 'SELECT id, tipo, quantidade, preco, data_operacao FROM acoes WHERE usuario_id = ? AND ativo = ?'; + $params = [$usuario_id, $ativo]; + if ($ignorar_id !== null) { + $sql .= ' AND id != ?'; + $params[] = $ignorar_id; + } + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $linhas = $stmt->fetchAll(); + foreach ($linhas as &$l) { + $l['_ordem'] = (int) $l['id']; + } + return $linhas; +} + +/** + * Recalcula e salva a posição consolidada (quantidade total + PM) de um + * ativo para um usuário, simulando todo o histórico de compras/vendas dele. + * Se não sobrar quantidade nenhuma, a posição é removida. + */ +function ao_recalcular_posicao_acao(PDO $pdo, $usuario_id, $ativo) { + $linhas = ao_buscar_transacoes_ativo($pdo, $usuario_id, $ativo); + $sim = ao_simular_transacoes($linhas); + + if ($sim['qtd'] <= 0) { + $pdo->prepare('DELETE FROM acoes_posicoes WHERE usuario_id = ? AND ativo = ?')->execute([$usuario_id, $ativo]); + return; + } + + // A categoria do ativo é a mesma em todas as operações dele (garantido na + // validação do save) — pega de qualquer uma para gravar na posição. + $stmtCategoria = $pdo->prepare('SELECT categoria FROM acoes WHERE usuario_id = ? AND ativo = ? LIMIT 1'); + $stmtCategoria->execute([$usuario_id, $ativo]); + $categoria = $stmtCategoria->fetchColumn() ?: 'acao'; + + $stmt = $pdo->prepare( + 'INSERT INTO acoes_posicoes (usuario_id, ativo, categoria, quantidade, preco_medio) VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE categoria = VALUES(categoria), quantidade = VALUES(quantidade), preco_medio = VALUES(preco_medio)' + ); + $stmt->execute([$usuario_id, $ativo, $categoria, $sim['qtd'], $sim['pm']]); +} + +/** Converte "38,42" ou "38.42" em float. Retorna null se não for um número válido. */ +function ao_parse_decimal_br($valor) { + $valor = str_replace(',', '.', trim((string) $valor)); + return is_numeric($valor) ? (float) $valor : null; +} + +/** Badge colorido com o rótulo (Ação/ETF/BDR/FII) de uma categoria de ativo. */ +function ao_badge_categoria($categoria) { + $classes = ['acao' => 'ao-badge-neutro', 'etf' => 'ao-badge-info', 'bdr' => 'ao-badge-alerta', 'fii' => 'ao-badge-fii']; + $classe = $classes[$categoria] ?? 'ao-badge-neutro'; + $rotulo = AO_CATEGORIAS_ATIVO[$categoria] ?? $categoria; + return '' . htmlspecialchars($rotulo) . ''; +} + +$erro = null; +$sucesso = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'excluir') { + $id = (int) ($_POST['id'] ?? 0); + $stmt = $pdo->prepare('SELECT ativo FROM acoes WHERE id = ? AND usuario_id = ?'); + $stmt->execute([$id, $ao_usuario_id]); + $linha = $stmt->fetch(); + + if ($linha) { + // Antes de excluir, confere se remover esta operação deixaria alguma + // venda posterior sem ações suficientes (ex: excluir uma compra cujas + // ações já foram vendidas depois). + $sim = ao_simular_transacoes(ao_buscar_transacoes_ativo($pdo, $ao_usuario_id, $linha['ativo'], $id)); + if (!$sim['ok']) { + $erro = 'Não é possível excluir: isso deixaria uma venda sem ações suficientes na carteira.'; + } else { + $pdo->prepare('DELETE FROM acoes WHERE id = ? AND usuario_id = ?')->execute([$id, $ao_usuario_id]); + ao_recalcular_posicao_acao($pdo, $ao_usuario_id, $linha['ativo']); + header('Location: acoes.php?excluido=1'); + exit; + } + } else { + header('Location: acoes.php?excluido=1'); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $conta_id = (int) ($_POST['conta_id'] ?? 0); + $ativo = strtoupper(trim($_POST['ativo'] ?? '')); + $categoria = (string) ($_POST['categoria'] ?? ''); + $tipo = ($_POST['tipo'] ?? '') === 'venda' ? 'venda' : 'compra'; + $quantidade = (int) ($_POST['quantidade'] ?? 0); + $data_operacao = trim($_POST['data_operacao'] ?? ''); + $preco_bruto = trim($_POST['preco'] ?? ''); + $preco = ao_parse_decimal_br($preco_bruto); + $ativo_antigo = null; + + if (!array_key_exists($categoria, AO_CATEGORIAS_ATIVO)) { + $categoria = 'acao'; + } + + // A conta precisa existir e pertencer ao usuário logado — impede que + // alguém forje o id de uma conta de outra pessoa no formulário. + $stmtConta = $pdo->prepare('SELECT id FROM contas WHERE id = ? AND usuario_id = ?'); + $stmtConta->execute([$conta_id, $ao_usuario_id]); + $contaValida = (bool) $stmtConta->fetch(); + + if (!$contaValida) { + $erro = 'Selecione uma conta válida.'; + } elseif ($ativo === '' || $quantidade <= 0 || $preco === null || $preco <= 0) { + $erro = 'Preencha código do ativo, quantidade (maior que zero) e preço (maior que zero) corretamente.'; + } elseif ($data_operacao !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $data_operacao)) { + $erro = 'Data da operação inválida.'; + } else { + $ignorar_id = $id > 0 ? $id : null; + + if ($id > 0) { + $stmtAntigo = $pdo->prepare('SELECT ativo FROM acoes WHERE id = ? AND usuario_id = ?'); + $stmtAntigo->execute([$id, $ao_usuario_id]); + $antigo = $stmtAntigo->fetch(); + if (!$antigo) { + $erro = 'Operação não encontrada.'; + } else { + $ativo_antigo = $antigo['ativo']; + } + } + + if (!$erro) { + // A categoria é do ativo, não da operação — todas as operações de + // um mesmo código precisam concordar (evita PETR4 virar "ETF" numa + // operação e "Ação" noutra por engano). + $sqlCategoriaExistente = 'SELECT DISTINCT categoria FROM acoes WHERE usuario_id = ? AND ativo = ?'; + $paramsCategoriaExistente = [$ao_usuario_id, $ativo]; + if ($ignorar_id !== null) { + $sqlCategoriaExistente .= ' AND id != ?'; + $paramsCategoriaExistente[] = $ignorar_id; + } + $stmtCategoriaExistente = $pdo->prepare($sqlCategoriaExistente); + $stmtCategoriaExistente->execute($paramsCategoriaExistente); + $categoriaExistente = $stmtCategoriaExistente->fetchColumn(); + + if ($categoriaExistente !== false && $categoriaExistente !== $categoria) { + $erro = 'Já existe(m) operação(ões) de ' . htmlspecialchars($ativo) . ' cadastrada(s) como "' + . htmlspecialchars(AO_CATEGORIAS_ATIVO[$categoriaExistente]) . '". Selecione a mesma categoria ' + . 'para manter a carteira consistente (ou edite as operações antigas se o código estiver errado).'; + } + } + + if (!$erro) { + // Valida o ativo de destino incluindo esta operação (nova ou editada). + $linhasDestino = ao_buscar_transacoes_ativo($pdo, $ao_usuario_id, $ativo, $ignorar_id); + $linhasDestino[] = [ + 'tipo' => $tipo, 'quantidade' => $quantidade, 'preco' => $preco, + 'data_operacao' => $data_operacao !== '' ? $data_operacao : null, + '_ordem' => $id > 0 ? $id : PHP_INT_MAX, + ]; + $simDestino = ao_simular_transacoes($linhasDestino); + + if (!$simDestino['ok']) { + $erro = 'Essa venda deixaria a quantidade de ' . htmlspecialchars($ativo) . ' negativa em alguma data — ' + . 'você não pode vender mais ações do que possuía até aquele momento.'; + } elseif ($ativo_antigo !== null && $ativo_antigo !== $ativo) { + // O código do ativo mudou nesta edição: confere se o ativo antigo + // continua consistente sem esta operação (que está saindo dele). + $simAntigo = ao_simular_transacoes(ao_buscar_transacoes_ativo($pdo, $ao_usuario_id, $ativo_antigo, $id)); + if (!$simAntigo['ok']) { + $erro = 'Não é possível mover esta operação para outro ativo: sobraria uma venda de ' + . htmlspecialchars($ativo_antigo) . ' sem ações suficientes.'; + } + } + } + + if (!$erro) { + try { + if ($id > 0) { + $stmt = $pdo->prepare('UPDATE acoes SET conta_id=?, ativo=?, categoria=?, tipo=?, quantidade=?, data_operacao=?, preco=? WHERE id=? AND usuario_id=?'); + $stmt->execute([$conta_id, $ativo, $categoria, $tipo, $quantidade, $data_operacao !== '' ? $data_operacao : null, $preco, $id, $ao_usuario_id]); + } else { + $stmt = $pdo->prepare('INSERT INTO acoes (usuario_id, conta_id, ativo, categoria, tipo, quantidade, data_operacao, preco) VALUES (?,?,?,?,?,?,?,?)'); + $stmt->execute([$ao_usuario_id, $conta_id, $ativo, $categoria, $tipo, $quantidade, $data_operacao !== '' ? $data_operacao : null, $preco]); + } + + ao_recalcular_posicao_acao($pdo, $ao_usuario_id, $ativo); + if ($ativo_antigo !== null && $ativo_antigo !== $ativo) { + ao_recalcular_posicao_acao($pdo, $ao_usuario_id, $ativo_antigo); + } + + header('Location: acoes.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = 'Erro ao salvar: ' . $e->getMessage(); + } + } + } +} + +$editando = null; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM acoes WHERE id = ? AND usuario_id = ?'); + $stmt->execute([(int) $_GET['editar'], $ao_usuario_id]); + $editando = $stmt->fetch() ?: null; +} + +// Se deu erro no envio, mantemos o que foi digitado na tela em vez de limpar tudo. +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar' && $erro && $erro !== 'Operação não encontrada.') { + $editando = [ + 'id' => $id, + 'conta_id' => $conta_id, + 'ativo' => $ativo, + 'categoria' => $categoria, + 'tipo' => $tipo, + 'quantidade' => $quantidade, + 'data_operacao' => $data_operacao, + 'preco' => $preco_bruto, + ]; +} + +if (isset($_GET['salvo'])) $sucesso = 'Operação salva com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Operação excluída.'; + +$stmtContas = $pdo->prepare( + 'SELECT ct.id, ct.nome AS conta_nome, cr.nome AS corretora_nome + FROM contas ct + INNER JOIN corretoras cr ON cr.id = ct.corretora_id + WHERE ct.usuario_id = ? + ORDER BY cr.nome, ct.nome' +); +$stmtContas->execute([$ao_usuario_id]); +$contas = $stmtContas->fetchAll(); + +$stmtPosicoes = $pdo->prepare('SELECT * FROM acoes_posicoes WHERE usuario_id = ? ORDER BY ativo'); +$stmtPosicoes->execute([$ao_usuario_id]); +$posicoes = $stmtPosicoes->fetchAll(); + +$stmtOperacoes = $pdo->prepare( + 'SELECT a.*, ct.nome AS conta_nome, cr.nome AS corretora_nome + FROM acoes a + LEFT JOIN contas ct ON ct.id = a.conta_id + LEFT JOIN corretoras cr ON cr.id = ct.corretora_id + WHERE a.usuario_id = ? + ORDER BY (a.data_operacao IS NULL), a.data_operacao DESC, a.id DESC' +); +$stmtOperacoes->execute([$ao_usuario_id]); +$operacoes = $stmtOperacoes->fetchAll(); + +require __DIR__ . '/includes/header.php'; +?> + +
+

Minha carteira de ações

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

Antes de cadastrar uma operação, você precisa ter uma corretora e uma conta cadastradas. + Cadastre uma corretora e depois cadastre uma conta nela.

+
+ + +
+ +
+ +
+
Carteira consolidada
+ + + + + + + + + + + + + + + + + + +
AtivoCategoriaQuantidadePreço médio (PM)Valor investido
Nenhuma ação em carteira no momento.
R$ R$
+
+ +
+
Histórico de operações
+ + + + + + + + + + + + + + + + + + + + + +
DataAtivoCategoriaContaTipoQuantidadePreço
Nenhuma operação cadastrada ainda.
R$ + Editar +
+ + + +
+
+
+ + + + + + diff --git a/assets/css/identidade-visual-cinza.css b/assets/css/identidade-visual-cinza.css new file mode 100644 index 0000000..b720151 --- /dev/null +++ b/assets/css/identidade-visual-cinza.css @@ -0,0 +1,322 @@ +/* ============================================================ + AçãoOpção — Identidade Visual (variação Cinza/Grafite) + Arquivo de variáveis e componentes base para o sistema PHP. + Inclua este arquivo após o reset/normalize do projeto: + + + Fontes (Google Fonts) — adicione no antes deste arquivo: + + + ============================================================ */ + +:root { + /* ---------- Paleta cinza/grafite (marca) ---------- */ + --ao-neutral-50: #F3F1ED; /* fornecida */ + --ao-neutral-100: #E6E3DB; + --ao-neutral-200: #D7D1C6; /* fornecida */ + --ao-neutral-300: #AAA8A5; + --ao-neutral-500: #6B6F77; /* fornecida — cor primária */ + --ao-neutral-700: #3C3F45; /* fornecida */ + --ao-neutral-800: #33353A; + --ao-neutral-900: #2B2D31; /* fornecida */ + + /* ---------- Neutros de apoio (mesma escala — paleta já é neutra) ---------- */ + --ao-gray-50: var(--ao-neutral-50); + --ao-gray-100: var(--ao-neutral-100); + --ao-gray-200: var(--ao-neutral-200); + --ao-gray-300: var(--ao-neutral-300); + --ao-gray-400: #8B8D8F; + --ao-gray-600: #55575C; + --ao-gray-800: var(--ao-neutral-800); + --ao-gray-900: var(--ao-neutral-900); + --ao-white: #FFFFFF; + + /* ---------- Cores semânticas (mercado) — universais, não mudam com a marca ---------- */ + --ao-alta: #3E8C4B; /* alta / ganho / positivo — usada em preenchimentos sólidos */ + --ao-alta-bg: #E4F1E6; + --ao-alta-text: var(--ao-alta); /* usada em texto/badge — troca de tom no modo escuro */ + --ao-baixa: #C1443B; /* baixa / perda / negativo — usada em preenchimentos sólidos */ + --ao-baixa-bg: #FBEAE8; + --ao-baixa-text: var(--ao-baixa); + --ao-alerta: #C98A2E; /* atenção / pendente */ + --ao-alerta-bg: #FBF1E1; + --ao-alerta-text: var(--ao-alerta); + --ao-info: #3B7A9E; /* informativo / neutro-azulado */ + --ao-info-bg: #E7F1F5; + --ao-info-text: var(--ao-info); + + /* ---------- Papéis funcionais ---------- */ + --ao-primary: var(--ao-neutral-700); + --ao-primary-hover: var(--ao-neutral-900); + --ao-primary-soft: var(--ao-neutral-100); + --ao-on-primary: var(--ao-white); /* cor do texto sobre botões/elementos "primary" */ + --ao-accent: var(--ao-neutral-500); + --ao-bg-page: var(--ao-neutral-50); + --ao-bg-surface: var(--ao-white); + --ao-border: var(--ao-neutral-200); + --ao-text-strong: var(--ao-neutral-900); + --ao-text-body: var(--ao-neutral-800); + --ao-text-muted: var(--ao-gray-600); + --ao-text-on-dark: var(--ao-white); + --ao-focus-ring: rgba(107, 111, 119, 0.45); + + /* ---------- Tipografia ---------- */ + --ao-font-heading: 'Poppins', 'Segoe UI', Arial, sans-serif; + --ao-font-body: 'Inter', 'Segoe UI', Arial, sans-serif; + --ao-font-mono: 'IBM Plex Mono', 'Roboto Mono', Consolas, monospace; /* preços, tickers, %s */ + + --ao-fs-xs: 0.75rem; + --ao-fs-sm: 0.875rem; + --ao-fs-base: 1rem; + --ao-fs-lg: 1.125rem; + --ao-fs-xl: 1.375rem; + --ao-fs-2xl: 1.75rem; + --ao-fs-3xl: 2.25rem; + + /* ---------- Espaçamento / raio / sombra ---------- */ + --ao-space-1: 4px; + --ao-space-2: 8px; + --ao-space-3: 12px; + --ao-space-4: 16px; + --ao-space-5: 24px; + --ao-space-6: 32px; + --ao-space-8: 48px; + + --ao-radius-sm: 6px; + --ao-radius-md: 10px; + --ao-radius-lg: 16px; + --ao-radius-pill: 999px; + + --ao-shadow-sm: 0 1px 2px rgba(43, 45, 49, 0.07); + --ao-shadow-md: 0 4px 12px rgba(43, 45, 49, 0.09); + --ao-shadow-lg: 0 12px 32px rgba(43, 45, 49, 0.16); +} + +/* ============================================================ + Base + ============================================================ */ +body { + background: var(--ao-bg-page); + color: var(--ao-text-body); + font-family: var(--ao-font-body); + font-size: var(--ao-fs-base); + line-height: 1.55; +} + +h1, h2, h3, h4, .ao-heading { + font-family: var(--ao-font-heading); + color: var(--ao-text-strong); + font-weight: 600; + line-height: 1.25; +} + +a { color: var(--ao-primary); } +a:hover { color: var(--ao-primary-hover); } + +.ao-numeric, .ao-price, .ao-ticker { + font-family: var(--ao-font-mono); + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + Botões + ============================================================ */ +.ao-btn { + display: inline-flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-body); + font-weight: 600; + font-size: var(--ao-fs-sm); + padding: 10px 18px; + border-radius: var(--ao-radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: background-color .15s ease, border-color .15s ease, color .15s ease; +} +.ao-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} +.ao-btn-primary { + background: var(--ao-primary); + color: var(--ao-on-primary); +} +.ao-btn-primary:hover { background: var(--ao-primary-hover); } + +.ao-btn-secondary { + background: var(--ao-neutral-100); + color: var(--ao-neutral-800); +} +.ao-btn-secondary:hover { background: var(--ao-neutral-200); } + +.ao-btn-outline { + background: transparent; + border-color: var(--ao-primary); + color: var(--ao-primary); +} +.ao-btn-outline:hover { background: var(--ao-bg-page); } + +.ao-btn-danger { + background: var(--ao-baixa); + color: var(--ao-white); +} + +/* ============================================================ + Cards / superfícies + ============================================================ */ +.ao-card { + background: var(--ao-bg-surface); + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-lg); + box-shadow: var(--ao-shadow-sm); + padding: var(--ao-space-5); +} +.ao-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--ao-space-4); +} + +/* ============================================================ + Badges / indicadores de mercado + ============================================================ */ +.ao-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--ao-fs-xs); + font-weight: 700; + letter-spacing: .02em; + padding: 3px 10px; + border-radius: var(--ao-radius-pill); +} +.ao-badge-alta { background: var(--ao-alta-bg); color: var(--ao-alta-text); } +.ao-badge-baixa { background: var(--ao-baixa-bg); color: var(--ao-baixa-text); } +.ao-badge-alerta { background: var(--ao-alerta-bg); color: var(--ao-alerta-text); } +.ao-badge-info { background: var(--ao-info-bg); color: var(--ao-info-text); } +.ao-badge-neutro { background: var(--ao-gray-100); color: var(--ao-gray-600); } + +/* ============================================================ + Tabelas (cotações, posições, histórico de operações) + ============================================================ */ +.ao-table { + width: 100%; + border-collapse: collapse; + background: var(--ao-bg-surface); + border-radius: var(--ao-radius-md); + overflow: hidden; +} +.ao-table th { + text-align: left; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-xs); + text-transform: uppercase; + letter-spacing: .04em; + color: var(--ao-text-muted); + background: var(--ao-bg-page); + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); +} +.ao-table td { + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); + font-size: var(--ao-fs-sm); +} +.ao-table tr:hover td { background: var(--ao-bg-page); } +.ao-table .ao-up { color: var(--ao-alta-text); } +.ao-table .ao-down { color: var(--ao-baixa-text); } + +/* ============================================================ + Formulários + ============================================================ */ +.ao-field label { + display: block; + font-size: var(--ao-fs-sm); + font-weight: 600; + color: var(--ao-text-strong); + margin-bottom: var(--ao-space-1); +} +.ao-field input, +.ao-field select, +.ao-field textarea { + width: 100%; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-sm); + padding: 10px 12px; + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-sm); + background: var(--ao-bg-surface); + color: var(--ao-text-body); +} +.ao-field input:focus, +.ao-field select:focus, +.ao-field textarea:focus { + outline: none; + border-color: var(--ao-primary); + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} + +/* ============================================================ + Topbar / navegação + ============================================================ */ +.ao-topbar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--ao-neutral-900); + color: var(--ao-white); + padding: var(--ao-space-3) var(--ao-space-5); +} +.ao-topbar a { color: var(--ao-neutral-200); } +.ao-topbar .ao-brand { + display: flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-heading); + font-weight: 700; + color: var(--ao-white); +} + +/* ============================================================ + Modo escuro + Ative adicionando data-ao-mode="escuro" na tag . + Como todos os componentes acima já leem cores por variável, + o modo escuro é só uma troca de tokens — nenhuma regra de + componente precisa ser duplicada. + ============================================================ */ +html[data-ao-mode="escuro"] { + --ao-bg-page: #151619; + --ao-bg-surface: #212326; + --ao-border: #323439; + --ao-text-strong: #F4F5F5; + --ao-text-body: #DBDDE1; + --ao-text-muted: #89909F; + + --ao-primary: var(--ao-neutral-300); + --ao-primary-hover: var(--ao-neutral-200); + --ao-primary-soft: #323439; + --ao-on-primary: var(--ao-neutral-900); + --ao-focus-ring: rgba(170, 168, 165, 0.5); + + --ao-shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); + --ao-shadow-md: 0 6px 16px rgba(0, 0, 0, .45); + --ao-shadow-lg: 0 18px 40px rgba(0, 0, 0, .55); + + /* as cores "sólidas" (--ao-alta/--ao-baixa/...) continuam iguais — + são usadas em botões de preenchimento cheio e já têm contraste + garantido. Só as versões usadas como TEXTO sobre fundo escuro + (badges, tabela) ficam mais claras para manter legibilidade. */ + --ao-alta-text: #7ED17A; + --ao-alta-bg: rgba(126, 209, 122, .16); + --ao-baixa-text: #FF8A7D; + --ao-baixa-bg: rgba(255, 138, 125, .16); + --ao-alerta-text: #F0B35C; + --ao-alerta-bg: rgba(240, 179, 92, .16); + --ao-info-text: #6FC1EE; + --ao-info-bg: rgba(111, 193, 238, .16); +} + +html[data-ao-mode="escuro"] .ao-btn-secondary { background: var(--ao-border); color: var(--ao-text-body); } +html[data-ao-mode="escuro"] .ao-badge-neutro { background: rgba(255, 255, 255, .08); color: var(--ao-text-muted); } diff --git a/assets/css/identidade-visual-marrom.css b/assets/css/identidade-visual-marrom.css new file mode 100644 index 0000000..0e362c3 --- /dev/null +++ b/assets/css/identidade-visual-marrom.css @@ -0,0 +1,322 @@ +/* ============================================================ + AçãoOpção — Identidade Visual (variação Marrom) + Arquivo de variáveis e componentes base para o sistema PHP. + Inclua este arquivo após o reset/normalize do projeto: + + + Fontes (Google Fonts) — adicione no antes deste arquivo: + + + ============================================================ */ + +:root { + /* ---------- Paleta marrom (marca) ---------- */ + --ao-brown-50: #F3E9DC; /* fornecida */ + --ao-brown-100: #E6D6BF; + --ao-brown-200: #D8C2A4; /* fornecida */ + --ao-brown-300: #C4A17A; + --ao-brown-500: #A6754C; /* fornecida — cor primária */ + --ao-brown-700: #6B3F2A; /* fornecida */ + --ao-brown-800: #533221; + --ao-brown-900: #3B2418; /* fornecida */ + + /* ---------- Neutros (cinza com sub-tom marrom) ---------- */ + --ao-gray-50: #F8F6F3; + --ao-gray-100: #F0EBE4; + --ao-gray-200: #DED4C8; + --ao-gray-300: #C2B4A3; + --ao-gray-400: #A0917F; + --ao-gray-600: #6B5D4E; + --ao-gray-800: #392F26; + --ao-gray-900: #201A15; + --ao-white: #FFFFFF; + + /* ---------- Cores semânticas (mercado) — universais, não mudam com a marca ---------- */ + --ao-alta: #4C8C3F; /* alta / ganho / positivo — usada em preenchimentos sólidos */ + --ao-alta-bg: #E5F1DE; + --ao-alta-text: var(--ao-alta); /* usada em texto/badge — troca de tom no modo escuro */ + --ao-baixa: #C1443B; /* baixa / perda / negativo — usada em preenchimentos sólidos */ + --ao-baixa-bg: #FBEAE8; + --ao-baixa-text: var(--ao-baixa); + --ao-alerta: #C98A2E; /* atenção / pendente */ + --ao-alerta-bg: #FBF1E1; + --ao-alerta-text: var(--ao-alerta); + --ao-info: #3B7A9E; /* informativo / neutro-azulado */ + --ao-info-bg: #E7F1F5; + --ao-info-text: var(--ao-info); + + /* ---------- Papéis funcionais ---------- */ + --ao-primary: var(--ao-brown-700); + --ao-primary-hover: var(--ao-brown-800); + --ao-primary-soft: var(--ao-brown-100); + --ao-on-primary: var(--ao-white); /* cor do texto sobre botões/elementos "primary" */ + --ao-accent: var(--ao-brown-500); + --ao-bg-page: var(--ao-brown-50); + --ao-bg-surface: var(--ao-white); + --ao-border: var(--ao-gray-200); + --ao-text-strong: var(--ao-gray-900); + --ao-text-body: var(--ao-gray-800); + --ao-text-muted: var(--ao-gray-600); + --ao-text-on-dark: var(--ao-white); + --ao-focus-ring: rgba(166, 117, 76, 0.45); + + /* ---------- Tipografia ---------- */ + --ao-font-heading: 'Poppins', 'Segoe UI', Arial, sans-serif; + --ao-font-body: 'Inter', 'Segoe UI', Arial, sans-serif; + --ao-font-mono: 'IBM Plex Mono', 'Roboto Mono', Consolas, monospace; /* preços, tickers, %s */ + + --ao-fs-xs: 0.75rem; + --ao-fs-sm: 0.875rem; + --ao-fs-base: 1rem; + --ao-fs-lg: 1.125rem; + --ao-fs-xl: 1.375rem; + --ao-fs-2xl: 1.75rem; + --ao-fs-3xl: 2.25rem; + + /* ---------- Espaçamento / raio / sombra ---------- */ + --ao-space-1: 4px; + --ao-space-2: 8px; + --ao-space-3: 12px; + --ao-space-4: 16px; + --ao-space-5: 24px; + --ao-space-6: 32px; + --ao-space-8: 48px; + + --ao-radius-sm: 6px; + --ao-radius-md: 10px; + --ao-radius-lg: 16px; + --ao-radius-pill: 999px; + + --ao-shadow-sm: 0 1px 2px rgba(32, 26, 21, 0.07); + --ao-shadow-md: 0 4px 12px rgba(32, 26, 21, 0.09); + --ao-shadow-lg: 0 12px 32px rgba(32, 26, 21, 0.16); +} + +/* ============================================================ + Base + ============================================================ */ +body { + background: var(--ao-bg-page); + color: var(--ao-text-body); + font-family: var(--ao-font-body); + font-size: var(--ao-fs-base); + line-height: 1.55; +} + +h1, h2, h3, h4, .ao-heading { + font-family: var(--ao-font-heading); + color: var(--ao-text-strong); + font-weight: 600; + line-height: 1.25; +} + +a { color: var(--ao-primary); } +a:hover { color: var(--ao-primary-hover); } + +.ao-numeric, .ao-price, .ao-ticker { + font-family: var(--ao-font-mono); + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + Botões + ============================================================ */ +.ao-btn { + display: inline-flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-body); + font-weight: 600; + font-size: var(--ao-fs-sm); + padding: 10px 18px; + border-radius: var(--ao-radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: background-color .15s ease, border-color .15s ease, color .15s ease; +} +.ao-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} +.ao-btn-primary { + background: var(--ao-primary); + color: var(--ao-on-primary); +} +.ao-btn-primary:hover { background: var(--ao-primary-hover); } + +.ao-btn-secondary { + background: var(--ao-brown-100); + color: var(--ao-brown-800); +} +.ao-btn-secondary:hover { background: var(--ao-brown-200); } + +.ao-btn-outline { + background: transparent; + border-color: var(--ao-primary); + color: var(--ao-primary); +} +.ao-btn-outline:hover { background: var(--ao-bg-page); } + +.ao-btn-danger { + background: var(--ao-baixa); + color: var(--ao-white); +} + +/* ============================================================ + Cards / superfícies + ============================================================ */ +.ao-card { + background: var(--ao-bg-surface); + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-lg); + box-shadow: var(--ao-shadow-sm); + padding: var(--ao-space-5); +} +.ao-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--ao-space-4); +} + +/* ============================================================ + Badges / indicadores de mercado + ============================================================ */ +.ao-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--ao-fs-xs); + font-weight: 700; + letter-spacing: .02em; + padding: 3px 10px; + border-radius: var(--ao-radius-pill); +} +.ao-badge-alta { background: var(--ao-alta-bg); color: var(--ao-alta-text); } +.ao-badge-baixa { background: var(--ao-baixa-bg); color: var(--ao-baixa-text); } +.ao-badge-alerta { background: var(--ao-alerta-bg); color: var(--ao-alerta-text); } +.ao-badge-info { background: var(--ao-info-bg); color: var(--ao-info-text); } +.ao-badge-neutro { background: var(--ao-gray-100); color: var(--ao-gray-600); } + +/* ============================================================ + Tabelas (cotações, posições, histórico de operações) + ============================================================ */ +.ao-table { + width: 100%; + border-collapse: collapse; + background: var(--ao-bg-surface); + border-radius: var(--ao-radius-md); + overflow: hidden; +} +.ao-table th { + text-align: left; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-xs); + text-transform: uppercase; + letter-spacing: .04em; + color: var(--ao-text-muted); + background: var(--ao-bg-page); + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); +} +.ao-table td { + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); + font-size: var(--ao-fs-sm); +} +.ao-table tr:hover td { background: var(--ao-bg-page); } +.ao-table .ao-up { color: var(--ao-alta-text); } +.ao-table .ao-down { color: var(--ao-baixa-text); } + +/* ============================================================ + Formulários + ============================================================ */ +.ao-field label { + display: block; + font-size: var(--ao-fs-sm); + font-weight: 600; + color: var(--ao-text-strong); + margin-bottom: var(--ao-space-1); +} +.ao-field input, +.ao-field select, +.ao-field textarea { + width: 100%; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-sm); + padding: 10px 12px; + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-sm); + background: var(--ao-bg-surface); + color: var(--ao-text-body); +} +.ao-field input:focus, +.ao-field select:focus, +.ao-field textarea:focus { + outline: none; + border-color: var(--ao-primary); + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} + +/* ============================================================ + Topbar / navegação + ============================================================ */ +.ao-topbar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--ao-brown-900); + color: var(--ao-white); + padding: var(--ao-space-3) var(--ao-space-5); +} +.ao-topbar a { color: var(--ao-brown-100); } +.ao-topbar .ao-brand { + display: flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-heading); + font-weight: 700; + color: var(--ao-white); +} + +/* ============================================================ + Modo escuro + Ative adicionando data-ao-mode="escuro" na tag . + Como todos os componentes acima já leem cores por variável, + o modo escuro é só uma troca de tokens — nenhuma regra de + componente precisa ser duplicada. + ============================================================ */ +html[data-ao-mode="escuro"] { + --ao-bg-page: #21140D; + --ao-bg-surface: #331F15; + --ao-border: #483023; + --ao-text-strong: #F6F5F4; + --ao-text-body: #E3DCD9; + --ao-text-muted: #AA8D7D; + + --ao-primary: var(--ao-brown-300); + --ao-primary-hover: var(--ao-brown-200); + --ao-primary-soft: #483023; + --ao-on-primary: var(--ao-brown-900); + --ao-focus-ring: rgba(196, 161, 122, 0.5); + + --ao-shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); + --ao-shadow-md: 0 6px 16px rgba(0, 0, 0, .45); + --ao-shadow-lg: 0 18px 40px rgba(0, 0, 0, .55); + + /* as cores "sólidas" (--ao-alta/--ao-baixa/...) continuam iguais — + são usadas em botões de preenchimento cheio e já têm contraste + garantido. Só as versões usadas como TEXTO sobre fundo escuro + (badges, tabela) ficam mais claras para manter legibilidade. */ + --ao-alta-text: #7ED17A; + --ao-alta-bg: rgba(126, 209, 122, .16); + --ao-baixa-text: #FF8A7D; + --ao-baixa-bg: rgba(255, 138, 125, .16); + --ao-alerta-text: #F0B35C; + --ao-alerta-bg: rgba(240, 179, 92, .16); + --ao-info-text: #6FC1EE; + --ao-info-bg: rgba(111, 193, 238, .16); +} + +html[data-ao-mode="escuro"] .ao-btn-secondary { background: var(--ao-border); color: var(--ao-text-body); } +html[data-ao-mode="escuro"] .ao-badge-neutro { background: rgba(255, 255, 255, .08); color: var(--ao-text-muted); } diff --git a/assets/css/identidade-visual-verde.css b/assets/css/identidade-visual-verde.css new file mode 100644 index 0000000..50a85bc --- /dev/null +++ b/assets/css/identidade-visual-verde.css @@ -0,0 +1,322 @@ +/* ============================================================ + AçãoOpção — Identidade Visual + Arquivo de variáveis e componentes base para o sistema PHP. + Inclua este arquivo após o reset/normalize do projeto: + + + Fontes (Google Fonts) — adicione no antes deste arquivo: + + + ============================================================ */ + +:root { + /* ---------- Paleta verde (marca) ---------- */ + --ao-green-50: #F7F9F4; + --ao-green-100: #EBEFE6; + --ao-green-200: #CFE0BC; /* fornecida */ + --ao-green-300: #99CD85; /* fornecida */ + --ao-green-500: #7FA653; /* fornecida — cor primária */ + --ao-green-700: #63783D; /* fornecida */ + --ao-green-800: #3D4A26; + --ao-green-900: #212915; + + /* ---------- Neutros (cinza com sub-tom verde) ---------- */ + --ao-gray-50: #F7F8F6; + --ao-gray-100: #EEF0EC; + --ao-gray-200: #DDE1D8; + --ao-gray-300: #C3C9BE; + --ao-gray-400: #9CA69A; + --ao-gray-600: #5D665A; + --ao-gray-800: #33392F; + --ao-gray-900: #1B1F17; + --ao-white: #FFFFFF; + + /* ---------- Cores semânticas (mercado) ---------- */ + --ao-alta: #4C8C3F; /* alta / ganho / positivo — usada em preenchimentos sólidos */ + --ao-alta-bg: #E5F1DE; + --ao-alta-text: var(--ao-alta); /* usada em texto/badge — troca de tom no modo escuro */ + --ao-baixa: #C1443B; /* baixa / perda / negativo — usada em preenchimentos sólidos */ + --ao-baixa-bg: #FBEAE8; + --ao-baixa-text: var(--ao-baixa); + --ao-alerta: #C98A2E; /* atenção / pendente */ + --ao-alerta-bg: #FBF1E1; + --ao-alerta-text: var(--ao-alerta); + --ao-info: #3B7A9E; /* informativo / neutro-azulado */ + --ao-info-bg: #E7F1F5; + --ao-info-text: var(--ao-info); + + /* ---------- Papéis funcionais ---------- */ + --ao-primary: var(--ao-green-700); + --ao-primary-hover: var(--ao-green-800); + --ao-primary-soft: var(--ao-green-100); + --ao-on-primary: var(--ao-white); /* cor do texto sobre botões/elementos "primary" */ + --ao-accent: var(--ao-green-500); + --ao-bg-page: var(--ao-green-50); + --ao-bg-surface: var(--ao-white); + --ao-border: var(--ao-gray-200); + --ao-text-strong: var(--ao-gray-900); + --ao-text-body: var(--ao-gray-800); + --ao-text-muted: var(--ao-gray-600); + --ao-text-on-dark: var(--ao-white); + --ao-focus-ring: rgba(127, 166, 83, 0.45); + + /* ---------- Tipografia ---------- */ + --ao-font-heading: 'Poppins', 'Segoe UI', Arial, sans-serif; + --ao-font-body: 'Inter', 'Segoe UI', Arial, sans-serif; + --ao-font-mono: 'IBM Plex Mono', 'Roboto Mono', Consolas, monospace; /* preços, tickers, %s */ + + --ao-fs-xs: 0.75rem; + --ao-fs-sm: 0.875rem; + --ao-fs-base: 1rem; + --ao-fs-lg: 1.125rem; + --ao-fs-xl: 1.375rem; + --ao-fs-2xl: 1.75rem; + --ao-fs-3xl: 2.25rem; + + /* ---------- Espaçamento / raio / sombra ---------- */ + --ao-space-1: 4px; + --ao-space-2: 8px; + --ao-space-3: 12px; + --ao-space-4: 16px; + --ao-space-5: 24px; + --ao-space-6: 32px; + --ao-space-8: 48px; + + --ao-radius-sm: 6px; + --ao-radius-md: 10px; + --ao-radius-lg: 16px; + --ao-radius-pill: 999px; + + --ao-shadow-sm: 0 1px 2px rgba(33, 41, 21, 0.06); + --ao-shadow-md: 0 4px 12px rgba(33, 41, 21, 0.08); + --ao-shadow-lg: 0 12px 32px rgba(33, 41, 21, 0.14); +} + +/* ============================================================ + Base + ============================================================ */ +body { + background: var(--ao-bg-page); + color: var(--ao-text-body); + font-family: var(--ao-font-body); + font-size: var(--ao-fs-base); + line-height: 1.55; +} + +h1, h2, h3, h4, .ao-heading { + font-family: var(--ao-font-heading); + color: var(--ao-text-strong); + font-weight: 600; + line-height: 1.25; +} + +a { color: var(--ao-primary); } +a:hover { color: var(--ao-primary-hover); } + +.ao-numeric, .ao-price, .ao-ticker { + font-family: var(--ao-font-mono); + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + Botões + ============================================================ */ +.ao-btn { + display: inline-flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-body); + font-weight: 600; + font-size: var(--ao-fs-sm); + padding: 10px 18px; + border-radius: var(--ao-radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: background-color .15s ease, border-color .15s ease, color .15s ease; +} +.ao-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} +.ao-btn-primary { + background: var(--ao-primary); + color: var(--ao-on-primary); +} +.ao-btn-primary:hover { background: var(--ao-primary-hover); } + +.ao-btn-secondary { + background: var(--ao-green-100); + color: var(--ao-green-800); +} +.ao-btn-secondary:hover { background: var(--ao-green-200); } + +.ao-btn-outline { + background: transparent; + border-color: var(--ao-primary); + color: var(--ao-primary); +} +.ao-btn-outline:hover { background: var(--ao-bg-page); } + +.ao-btn-danger { + background: var(--ao-baixa); + color: var(--ao-white); +} + +/* ============================================================ + Cards / superfícies + ============================================================ */ +.ao-card { + background: var(--ao-bg-surface); + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-lg); + box-shadow: var(--ao-shadow-sm); + padding: var(--ao-space-5); +} +.ao-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--ao-space-4); +} + +/* ============================================================ + Badges / indicadores de mercado + ============================================================ */ +.ao-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--ao-fs-xs); + font-weight: 700; + letter-spacing: .02em; + padding: 3px 10px; + border-radius: var(--ao-radius-pill); +} +.ao-badge-alta { background: var(--ao-alta-bg); color: var(--ao-alta-text); } +.ao-badge-baixa { background: var(--ao-baixa-bg); color: var(--ao-baixa-text); } +.ao-badge-alerta { background: var(--ao-alerta-bg); color: var(--ao-alerta-text); } +.ao-badge-info { background: var(--ao-info-bg); color: var(--ao-info-text); } +.ao-badge-neutro { background: var(--ao-gray-100); color: var(--ao-gray-600); } + +/* ============================================================ + Tabelas (cotações, posições, histórico de operações) + ============================================================ */ +.ao-table { + width: 100%; + border-collapse: collapse; + background: var(--ao-bg-surface); + border-radius: var(--ao-radius-md); + overflow: hidden; +} +.ao-table th { + text-align: left; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-xs); + text-transform: uppercase; + letter-spacing: .04em; + color: var(--ao-text-muted); + background: var(--ao-bg-page); + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); +} +.ao-table td { + padding: var(--ao-space-3) var(--ao-space-4); + border-bottom: 1px solid var(--ao-border); + font-size: var(--ao-fs-sm); +} +.ao-table tr:hover td { background: var(--ao-bg-page); } +.ao-table .ao-up { color: var(--ao-alta-text); } +.ao-table .ao-down { color: var(--ao-baixa-text); } + +/* ============================================================ + Formulários + ============================================================ */ +.ao-field label { + display: block; + font-size: var(--ao-fs-sm); + font-weight: 600; + color: var(--ao-text-strong); + margin-bottom: var(--ao-space-1); +} +.ao-field input, +.ao-field select, +.ao-field textarea { + width: 100%; + font-family: var(--ao-font-body); + font-size: var(--ao-fs-sm); + padding: 10px 12px; + border: 1px solid var(--ao-border); + border-radius: var(--ao-radius-sm); + background: var(--ao-bg-surface); + color: var(--ao-text-body); +} +.ao-field input:focus, +.ao-field select:focus, +.ao-field textarea:focus { + outline: none; + border-color: var(--ao-primary); + box-shadow: 0 0 0 3px var(--ao-focus-ring); +} + +/* ============================================================ + Topbar / navegação + ============================================================ */ +.ao-topbar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--ao-green-900); + color: var(--ao-white); + padding: var(--ao-space-3) var(--ao-space-5); +} +.ao-topbar a { color: var(--ao-green-100); } +.ao-topbar .ao-brand { + display: flex; + align-items: center; + gap: var(--ao-space-2); + font-family: var(--ao-font-heading); + font-weight: 700; + color: var(--ao-white); +} + +/* ============================================================ + Modo escuro + Ative adicionando data-ao-mode="escuro" na tag . + Como todos os componentes acima já leem cores por variável, + o modo escuro é só uma troca de tokens — nenhuma regra de + componente precisa ser duplicada. + ============================================================ */ +html[data-ao-mode="escuro"] { + --ao-bg-page: #191F0F; + --ao-bg-surface: #272F18; + --ao-border: #3B4724; + --ao-text-strong: #F5F5F4; + --ao-text-body: #DFE2DA; + --ao-text-muted: #99A682; + + --ao-primary: var(--ao-green-300); + --ao-primary-hover: var(--ao-green-200); + --ao-primary-soft: #3B4724; + --ao-on-primary: var(--ao-green-900); + --ao-focus-ring: rgba(153, 205, 133, 0.5); + + --ao-shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); + --ao-shadow-md: 0 6px 16px rgba(0, 0, 0, .45); + --ao-shadow-lg: 0 18px 40px rgba(0, 0, 0, .55); + + /* as cores "sólidas" (--ao-alta/--ao-baixa/...) continuam iguais — + são usadas em botões de preenchimento cheio e já têm contraste + garantido. Só as versões usadas como TEXTO sobre fundo escuro + (badges, tabela) ficam mais claras para manter legibilidade. */ + --ao-alta-text: #7ED17A; + --ao-alta-bg: rgba(126, 209, 122, .16); + --ao-baixa-text: #FF8A7D; + --ao-baixa-bg: rgba(255, 138, 125, .16); + --ao-alerta-text: #F0B35C; + --ao-alerta-bg: rgba(240, 179, 92, .16); + --ao-info-text: #6FC1EE; + --ao-info-bg: rgba(111, 193, 238, .16); +} + +html[data-ao-mode="escuro"] .ao-btn-secondary { background: var(--ao-border); color: var(--ao-text-body); } +html[data-ao-mode="escuro"] .ao-badge-neutro { background: rgba(255, 255, 255, .08); color: var(--ao-text-muted); } diff --git a/assets/img/logos/cinza/favicon.ico b/assets/img/logos/cinza/favicon.ico new file mode 100644 index 0000000..84bc21e Binary files /dev/null and b/assets/img/logos/cinza/favicon.ico differ diff --git a/assets/img/logos/cinza/svg/icone-cor.svg b/assets/img/logos/cinza/svg/icone-cor.svg new file mode 100644 index 0000000..cf7dabf --- /dev/null +++ b/assets/img/logos/cinza/svg/icone-cor.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/img/logos/marrom/favicon.ico b/assets/img/logos/marrom/favicon.ico new file mode 100644 index 0000000..7c961d5 Binary files /dev/null and b/assets/img/logos/marrom/favicon.ico differ diff --git a/assets/img/logos/marrom/svg/icone-cor.svg b/assets/img/logos/marrom/svg/icone-cor.svg new file mode 100644 index 0000000..0cd5d90 --- /dev/null +++ b/assets/img/logos/marrom/svg/icone-cor.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/img/logos/verde/favicon.ico b/assets/img/logos/verde/favicon.ico new file mode 100644 index 0000000..b258556 Binary files /dev/null and b/assets/img/logos/verde/favicon.ico differ diff --git a/assets/img/logos/verde/svg/icone-cor.svg b/assets/img/logos/verde/svg/icone-cor.svg new file mode 100644 index 0000000..f43305e --- /dev/null +++ b/assets/img/logos/verde/svg/icone-cor.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/configuracoes.php b/configuracoes.php new file mode 100644 index 0000000..117ca4d --- /dev/null +++ b/configuracoes.php @@ -0,0 +1,87 @@ + 'Desenvolvimento', + 'producao' => 'Produção', +]; + +$ao_mensagem = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ambiente'])) { + if (ao_salvar_ambiente($_POST['ambiente'])) { + // Redireciona para evitar reenvio do form ao atualizar a página. + header('Location: configuracoes.php?salvo=1'); + exit; + } else { + $ao_mensagem = ['tipo' => 'erro', 'texto' => 'Ambiente inválido — nada foi alterado.']; + } +} + +if (isset($_GET['salvo'])) { + $ao_mensagem = ['tipo' => 'sucesso', 'texto' => 'Ambiente atualizado com sucesso.']; +} + +require __DIR__ . '/includes/header.php'; +?> + +

Configurações do sistema

+ + +
+ +
+ + +
+
Ambiente
+

+ Define a URL base usada pelo sistema. Em desenvolvimento, use o XAMPP local; + em produção, use o domínio publicado. +

+ +
+ $url): ?> + + + + +
+
+ +
+
Ambiente ativo
+ + + + + + + + + +
Ambiente
URL base
HTTPS obrigatório
Banco de dados (host)
Banco de dados (nome)
Usuário do banco
+

+ A senha do banco não é exibida aqui por segurança. Para alterar usuário/senha de cada + ambiente, edite includes/config.php (bloco AO_BANCOS) diretamente no servidor. +

+
+ + diff --git a/contas.php b/contas.php new file mode 100644 index 0000000..a6145a4 --- /dev/null +++ b/contas.php @@ -0,0 +1,177 @@ +prepare('SELECT COUNT(*) FROM acoes WHERE conta_id = ? AND usuario_id = ?'); + $stmt->execute([$id, $ao_usuario_id]); + if ((int) $stmt->fetchColumn() > 0) { + $erro = 'Não é possível excluir: esta conta já tem ações registradas nela.'; + } else { + $pdo->prepare('DELETE FROM contas WHERE id = ? AND usuario_id = ?')->execute([$id, $ao_usuario_id]); + header('Location: contas.php?excluido=1'); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $corretora_id = (int) ($_POST['corretora_id'] ?? 0); + $nome = trim($_POST['nome'] ?? ''); + + // A corretora é um catálogo global — só precisa existir, não precisa + // pertencer a este usuário. + $stmtCorretora = $pdo->prepare('SELECT id FROM corretoras WHERE id = ?'); + $stmtCorretora->execute([$corretora_id]); + $corretoraValida = (bool) $stmtCorretora->fetch(); + + if ($nome === '' || !$corretoraValida) { + $erro = 'Selecione uma corretora válida e informe o nome da conta.'; + } else { + // O limite de até 3 contas é por usuário dentro de cada corretora — + // não pelo total de contas de todo mundo naquela corretora. + $stmtCount = $pdo->prepare('SELECT COUNT(*) FROM contas WHERE corretora_id = ? AND usuario_id = ? AND id != ?'); + $stmtCount->execute([$corretora_id, $ao_usuario_id, $id ?: 0]); + if ((int) $stmtCount->fetchColumn() >= AO_MAX_CONTAS_POR_CORRETORA) { + $erro = 'Esta corretora já tem o máximo de ' . AO_MAX_CONTAS_POR_CORRETORA . ' contas.'; + } else { + try { + if ($id > 0) { + $stmt = $pdo->prepare('UPDATE contas SET corretora_id = ?, nome = ? WHERE id = ? AND usuario_id = ?'); + $stmt->execute([$corretora_id, $nome, $id, $ao_usuario_id]); + } else { + $stmt = $pdo->prepare('INSERT INTO contas (corretora_id, usuario_id, nome) VALUES (?, ?, ?)'); + $stmt->execute([$corretora_id, $ao_usuario_id, $nome]); + } + header('Location: contas.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = ($e->getCode() === '23000') ? 'Essa corretora já tem uma conta com esse nome.' : ('Erro ao salvar: ' . $e->getMessage()); + } + } + } +} + +$editando = null; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM contas WHERE id = ? AND usuario_id = ?'); + $stmt->execute([(int) $_GET['editar'], $ao_usuario_id]); + $editando = $stmt->fetch() ?: null; +} elseif (isset($_GET['corretora'])) { + // Vindo de "Ver contas" em Corretoras: só pré-seleciona a corretora no formulário. + $editando = ['id' => 0, 'corretora_id' => (int) $_GET['corretora'], 'nome' => '']; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar' && $erro) { + $editando = ['id' => $id, 'corretora_id' => $corretora_id, 'nome' => $nome]; +} + +if (isset($_GET['salvo'])) $sucesso = 'Conta salva com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Conta excluída.'; + +// Corretora é global: a lista de opções é a mesma para todos os usuários. +$stmtCorretoras = $pdo->query('SELECT id, nome FROM corretoras ORDER BY nome'); +$corretoras = $stmtCorretoras->fetchAll(); + +$stmt = $pdo->prepare( + 'SELECT ct.*, cr.nome AS corretora_nome, (SELECT COUNT(*) FROM acoes a WHERE a.conta_id = ct.id) AS total_acoes + FROM contas ct + INNER JOIN corretoras cr ON cr.id = ct.corretora_id + WHERE ct.usuario_id = ? + ORDER BY cr.nome, ct.nome' +); +$stmt->execute([$ao_usuario_id]); +$contas = $stmt->fetchAll(); + +require __DIR__ . '/includes/header.php'; +?> + +

Contas

+ + +
+ + +
+ + + +
+

Nenhuma corretora cadastrada ainda no sistema. Cadastre uma corretora antes de criar contas.

+
+ +
+
+

Você pode ter até 3 contas suas em cada corretora.

+
+ + +
+ + +
+
+ + +
+ + + Cancelar + +
+
+ + +
+
Contas cadastradas
+ + + + + + + + + + + + + + + + + +
CorretoraContaAções vinculadas
Nenhuma conta cadastrada ainda.
+ Editar +
+ + + +
+
+
+ + diff --git a/corretoras.php b/corretoras.php new file mode 100644 index 0000000..1346eda --- /dev/null +++ b/corretoras.php @@ -0,0 +1,143 @@ +prepare('SELECT COUNT(*) FROM contas WHERE corretora_id = ?'); + $stmt->execute([$id]); + if ((int) $stmt->fetchColumn() > 0) { + $erro = 'Não é possível excluir: esta corretora ainda tem contas cadastradas (suas ou de outro usuário). Exclua as contas primeiro.'; + } else { + $pdo->prepare('DELETE FROM corretoras WHERE id = ?')->execute([$id]); + header('Location: corretoras.php?excluido=1'); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $nome = trim($_POST['nome'] ?? ''); + + if ($nome === '') { + $erro = 'Informe o nome da corretora.'; + } else { + try { + if ($id > 0) { + $stmt = $pdo->prepare('UPDATE corretoras SET nome = ? WHERE id = ?'); + $stmt->execute([$nome, $id]); + } else { + $stmt = $pdo->prepare('INSERT INTO corretoras (nome) VALUES (?)'); + $stmt->execute([$nome]); + } + header('Location: corretoras.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = ($e->getCode() === '23000') ? 'Já existe uma corretora com esse nome.' : ('Erro ao salvar: ' . $e->getMessage()); + } + } +} + +$editando = null; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM corretoras WHERE id = ?'); + $stmt->execute([(int) $_GET['editar']]); + $editando = $stmt->fetch() ?: null; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar' && $erro) { + $editando = ['id' => $id, 'nome' => $nome]; +} + +if (isset($_GET['salvo'])) $sucesso = 'Corretora salva com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Corretora excluída.'; + +$stmt = $pdo->prepare( + 'SELECT c.*, (SELECT COUNT(*) FROM contas ct WHERE ct.corretora_id = c.id AND ct.usuario_id = ?) AS total_contas + FROM corretoras c ORDER BY c.nome' +); +$stmt->execute([$ao_usuario_id]); +$corretoras = $stmt->fetchAll(); + +require __DIR__ . '/includes/header.php'; +?> + +

Corretoras

+

+ Lista compartilhada por todos os usuários — cadastre aqui as instituições financeiras + (ex: XP Investimentos, Rico, Clear). Cada uma pode ter até 3 contas suas — cadastre as + contas em Contas. +

+ + +
+ + +
+ + +
+
+
+ + +
+ + +
+ + + Cancelar + +
+
+ +
+
Corretoras cadastradas
+ + + + + + + + + + + + + + + + +
NomeMinhas contas
Nenhuma corretora cadastrada ainda.
/ 3 + Ver contas + Editar +
+ + + +
+
+
+ + diff --git a/cotacoes.php b/cotacoes.php new file mode 100644 index 0000000..ca87872 --- /dev/null +++ b/cotacoes.php @@ -0,0 +1,237 @@ +prepare('INSERT IGNORE INTO cotacoes (ativo) VALUES (?)')->execute([$ativo]); + $pdo->prepare('INSERT INTO cotacoes_acompanhamentos (usuario_id, ativo) VALUES (?, ?)') + ->execute([$ao_usuario_id, $ativo]); + header('Location: cotacoes.php?acompanhado=1'); + exit; + } catch (PDOException $e) { + $erro = ($e->getCode() === '23000') ? ('Você já acompanha ' . $ativo . '.') : ('Erro ao salvar: ' . $e->getMessage()); + } + } +} + +// Para de acompanhar (não mexe na cotação compartilhada, só na lista pessoal). +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'parar') { + $id = (int) ($_POST['id'] ?? 0); + $pdo->prepare('DELETE FROM cotacoes_acompanhamentos WHERE id = ? AND usuario_id = ?')->execute([$id, $ao_usuario_id]); + header('Location: cotacoes.php?parado=1'); + exit; +} + +// Atualiza o preço de um ativo na tabela única/global — vale para todo mundo +// que acompanha esse ativo, não só para quem atualizou. Só deixamos atualizar +// o preço de um ativo que o próprio usuário acompanha (evita alguém ficar +// alterando cotação de ativo que nem apareceu pra ele na tela). +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'atualizar_preco') { + $ativo = strtoupper(trim($_POST['ativo'] ?? '')); + $preco_bruto = trim($_POST['preco'] ?? ''); + $preco = ao_cot_parse_decimal_br($preco_bruto); + + $stmtAcompanha = $pdo->prepare('SELECT 1 FROM cotacoes_acompanhamentos WHERE usuario_id = ? AND ativo = ?'); + $stmtAcompanha->execute([$ao_usuario_id, $ativo]); + + if (!$stmtAcompanha->fetch()) { + $erro = 'Você só pode atualizar o preço de ativos que você acompanha.'; + } elseif ($preco === null || $preco <= 0) { + $erro = 'Informe um preço válido (maior que zero).'; + } else { + $stmtAtual = $pdo->prepare('SELECT preco_atual FROM cotacoes WHERE ativo = ?'); + $stmtAtual->execute([$ativo]); + $preco_atual_antigo = (float) $stmtAtual->fetchColumn(); + + // Se é a primeira vez que alguém informa um preço para este ativo, não + // dá pra calcular variação a partir de zero — começa "zerada" (0%) em + // vez de mostrar um salto absurdo de +infinito. + $preco_anterior = $preco_atual_antigo > 0 ? $preco_atual_antigo : $preco; + + $stmt = $pdo->prepare('UPDATE cotacoes SET preco_anterior = ?, preco_atual = ?, atualizado_por = ? WHERE ativo = ?'); + $stmt->execute([$preco_anterior, $preco, $ao_usuario_id, $ativo]); + header('Location: cotacoes.php?atualizado=1'); + exit; + } +} + +if (isset($_GET['acompanhado'])) $sucesso = 'Ativo adicionado à sua lista de acompanhamento.'; +if (isset($_GET['parado'])) $sucesso = 'Você parou de acompanhar esse ativo.'; +if (isset($_GET['atualizado'])) $sucesso = 'Cotação atualizada — todo mundo que acompanha esse ativo já vê o novo preço.'; + +// Mantém o que foi digitado no formulário de "acompanhar" se deu erro. +$ativo_digitado = ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'acompanhar') + ? (string) ($_POST['ativo'] ?? '') + : ''; + +// A lista de preços vem sempre da tabela "cotacoes" (única/global) — aqui só +// filtramos para os ativos que ESTE usuário decidiu acompanhar. +$stmt = $pdo->prepare( + 'SELECT ca.id AS acompanhamento_id, c.ativo, c.preco_atual, c.preco_anterior, c.atualizado_em, c.atualizado_por, + u.nome AS atualizado_por_nome + FROM cotacoes_acompanhamentos ca + INNER JOIN cotacoes c ON c.ativo = ca.ativo + LEFT JOIN usuarios u ON u.id = c.atualizado_por + WHERE ca.usuario_id = ? + ORDER BY c.ativo' +); +$stmt->execute([$ao_usuario_id]); +$acompanhados = $stmt->fetchAll(); + +// Calcula a variação percentual de cada um e separa em altas (>= 0%) e +// baixas (< 0%) para as duas grids pedidas. +foreach ($acompanhados as &$a) { + $anterior = (float) $a['preco_anterior']; + $atual = (float) $a['preco_atual']; + $a['tem_cotacao'] = $a['atualizado_por'] !== null; + $a['variacao'] = $anterior > 0 ? (($atual - $anterior) / $anterior) * 100 : 0.0; +} +unset($a); + +$altas = array_values(array_filter($acompanhados, fn($a) => $a['variacao'] >= 0)); +$baixas = array_values(array_filter($acompanhados, fn($a) => $a['variacao'] < 0)); + +usort($altas, fn($a, $b) => $b['variacao'] <=> $a['variacao']); +usort($baixas, fn($a, $b) => $a['variacao'] <=> $b['variacao']); + +require __DIR__ . '/includes/header.php'; + +/** Uma linha da grid de cotações (compartilhada entre a grid de altas e a de baixas). */ +function ao_cot_linha(array $a) { + $classe = $a['variacao'] >= 0 ? 'ao-badge-alta' : 'ao-badge-baixa'; + $seta = $a['variacao'] >= 0 ? '▲' : '▼'; + ?> + + + + + R$ + + sem cotação ainda + + + + + % + + + + + + + em + + — + + + +
+ + + + +
+
+ + + +
+ + + + +

Cotações do dia

+ + +
+ + +
+ + +
+
Acompanhar novo ativo
+

+ A cotação é compartilhada: qualquer pessoa que também acompanhe o ativo pode + atualizar o preço, e todo mundo que o acompanha vê o valor mais recente. +

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

Você ainda não está acompanhando nenhum ativo. Use o formulário acima para adicionar o primeiro.

+
+ + +
+
Altas do dia
+ + + + + + + + + + +
AtivoÚltimoVariaçãoÚltima atualização
Nenhum ativo em alta no momento.
+
+ +
+
Baixas do dia
+ + + + + + + + + + +
AtivoÚltimoVariaçãoÚltima atualização
Nenhum ativo em baixa no momento.
+
+ + + + diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..add840f --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,105 @@ + ['arquivo' => 'usuarios.php', 'nome' => 'Usuários'], + 'perfis' => ['arquivo' => 'perfis.php', 'nome' => 'Perfis'], + 'paginas_admin' => ['arquivo' => 'paginas_admin.php', 'nome' => 'Páginas'], +]; + +function ao_usuario_logado() { + return $_SESSION['ao_usuario'] ?? null; +} + +function ao_exigir_login() { + if (!ao_usuario_logado()) { + $_SESSION['ao_pos_login'] = $_SERVER['REQUEST_URI'] ?? 'index.php'; + header('Location: login.php'); + exit; + } +} + +/** true se o usuário logado pode acessar a página de negócio $chave (tabela `paginas`). */ +function ao_tem_permissao($chave) { + $usuario = ao_usuario_logado(); + if (!$usuario) { + return false; + } + if (!empty($usuario['acesso_total'])) { + return true; + } + return in_array($chave, $usuario['paginas'] ?? [], true); +} + +/** Carrega os dados de sessão do usuário a partir do banco (login e refresh de permissões). */ +function ao_carregar_sessao_usuario(PDO $pdo, array $usuarioDb, array $perfilDb) { + $paginas = []; + if (empty($perfilDb['acesso_total'])) { + $stmt = $pdo->prepare( + 'SELECT p.chave FROM paginas p + INNER JOIN perfil_paginas pp ON pp.pagina_id = p.id + WHERE pp.perfil_id = ? AND p.ativo = 1' + ); + $stmt->execute([$perfilDb['id']]); + $paginas = array_column($stmt->fetchAll(), 'chave'); + } + + $_SESSION['ao_usuario'] = [ + 'id' => (int) $usuarioDb['id'], + 'nome' => $usuarioDb['nome'], + 'email' => $usuarioDb['email'], + 'perfil_id' => (int) $perfilDb['id'], + 'perfil_nome' => $perfilDb['nome'], + 'acesso_total' => (bool) $perfilDb['acesso_total'], + 'paginas' => $paginas, + ]; +} + +function ao_pagina_acesso_negado($ao_tema_atual = null) { + http_response_code(403); + $cor = $ao_tema_atual['cor'] ?? '#7FA653'; + echo '' + . 'Acesso negado — AçãoOpção' + . '' + . '' + . '
' + . '
🔒
' + . '

Acesso negado

' + . '

Seu perfil não tem permissão para acessar esta página. ' + . 'Fale com um administrador se precisar desse acesso.

' + . 'Voltar' + . '
'; + exit; +} + +/** Exige login e, se $chave for informada, permissão para aquela página de negócio. */ +function ao_exigir_permissao($chave = null, $ao_tema_atual = null) { + ao_exigir_login(); + if ($chave !== null && !ao_tem_permissao($chave)) { + ao_pagina_acesso_negado($ao_tema_atual); + } +} + +/** Exige login e acesso_total (para as páginas de administração do próprio sistema). */ +function ao_exigir_acesso_total($ao_tema_atual = null) { + ao_exigir_login(); + $usuario = ao_usuario_logado(); + if (empty($usuario['acesso_total'])) { + ao_pagina_acesso_negado($ao_tema_atual); + } +} diff --git a/includes/config.example.php b/includes/config.example.php new file mode 100644 index 0000000..2e9a907 --- /dev/null +++ b/includes/config.example.php @@ -0,0 +1,76 @@ + 'http://localhost:8080/claude/AcaoOpcao/', + 'producao' => 'https://acaoeopcaoderivativos.systems/', +]; + +function ao_ler_ambiente() { + $padrao = 'desenvolvimento'; + if (!file_exists(AO_AMBIENTE_ARQUIVO)) { + return $padrao; + } + $conteudo = json_decode((string) file_get_contents(AO_AMBIENTE_ARQUIVO), true); + $ambiente = is_array($conteudo) ? ($conteudo['ambiente'] ?? $padrao) : $padrao; + return array_key_exists($ambiente, AO_URLS) ? $ambiente : $padrao; +} + +function ao_salvar_ambiente($ambiente) { + if (!array_key_exists($ambiente, AO_URLS)) { + return false; + } + $ok = file_put_contents( + AO_AMBIENTE_ARQUIVO, + json_encode(['ambiente' => $ambiente], JSON_PRETTY_PRINT) + ); + return $ok !== false; +} + +// Constantes disponíveis para o resto do sistema: +// AO_AMBIENTE 'desenvolvimento' | 'producao' +// AO_BASE_URL URL completa do ambiente ativo +// AO_IS_PRODUCAO true quando o ambiente ativo é produção (https) +define('AO_AMBIENTE', ao_ler_ambiente()); +define('AO_BASE_URL', AO_URLS[AO_AMBIENTE]); +define('AO_IS_PRODUCAO', AO_AMBIENTE === 'producao'); + +// Credenciais do banco de dados MySQL por ambiente. Troque os valores de +// "producao" diretamente aqui, editando o arquivo no servidor — por segurança, +// evite colar senha de produção em e-mail, chat ou qualquer lugar fora deste +// arquivo. O ambiente ativo é escolhido na tela Configurações do sistema. +const AO_BANCOS = [ + 'desenvolvimento' => [ + 'host' => 'localhost', + 'nome' => 'acaoopcao', + 'usuario' => 'josima70_josimar', + 'senha' => 'Josim@r230976', + ], + 'producao' => [ + 'host' => 'database', // ajuste se o banco de produção estiver em outro host + 'nome' => 'acaoopcao', + 'usuario' => 'root', + 'senha' => 'Josim@r230976', + ], +]; + +define('AO_DB_HOST', AO_BANCOS[AO_AMBIENTE]['host']); +define('AO_DB_NOME', AO_BANCOS[AO_AMBIENTE]['nome']); +define('AO_DB_USUARIO', AO_BANCOS[AO_AMBIENTE]['usuario']); +define('AO_DB_SENHA', AO_BANCOS[AO_AMBIENTE]['senha']); + +/** + * Monta uma URL absoluta a partir de um caminho relativo ao projeto, + * usando a URL base do ambiente ativo. Útil para links que precisam + * ser absolutos (ex.: compartilhamento, tag canonical, e-mails). + * Os links internos do próprio sistema continuam relativos e não + * precisam desta função para funcionar nos dois ambientes. + */ +function ao_url($caminho = '') { + return rtrim(AO_BASE_URL, '/') . '/' . ltrim($caminho, '/'); +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..c9f1993 --- /dev/null +++ b/includes/db.php @@ -0,0 +1,35 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); +} catch (PDOException $e) { + http_response_code(500); + die( + '
' . + '

Não foi possível conectar ao banco de dados

' . + '

Ambiente ativo: ' . htmlspecialchars(AO_AMBIENTE) . '. Verifique se o MySQL está ' . + 'ativo, se o banco ' . htmlspecialchars(AO_DB_NOME) . ' foi criado a partir de ' . + 'includes/esquema.sql, e se o usuário/senha em includes/config.php ' . + '(bloco AO_BANCOS) estão corretos para este ambiente.

' . + '

Detalhe técnico: ' . htmlspecialchars($e->getMessage()) . '

' . + '
' + ); +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..ce8138f --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,6 @@ + + + + diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..ca9c127 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,232 @@ +query('SELECT chave, nome, arquivo FROM paginas WHERE ativo = 1 ORDER BY ordem, nome')->fetchAll(); + +// "Contas" não fica mais no menu principal — mora no submenu do usuário. +// Buscamos os dados dela aqui (nome/arquivo, caso alguém renomeie em +// Páginas) para montar o link lá embaixo, já checando a permissão normal. +$ao_pagina_contas = null; +foreach ($ao_nav_paginas as $p) { + if ($p['chave'] === 'contas') { + $ao_pagina_contas = $p; + break; + } +} +?> + + + + + +AçãoOpção<?= isset($titulo_pagina) ? ' — ' . htmlspecialchars($titulo_pagina) : '' ?> + + + + + + + + + +
+
+ + AçãoOpção +
+ +
+
+ +
+ + + + Minha conta + +
+
Identidade visual
+
+ $info): ?> + + + + + +
+ +
+ Sair +
+
+
+
+ +
diff --git a/includes/theme.php b/includes/theme.php new file mode 100644 index 0000000..a615655 --- /dev/null +++ b/includes/theme.php @@ -0,0 +1,89 @@ + [ + 'nome' => 'Verde', + 'css' => 'assets/css/identidade-visual-verde.css', + 'favicon' => 'assets/img/logos/verde/favicon.ico', + 'icone' => 'assets/img/logos/verde/svg/icone-cor.svg', + 'cor' => '#7FA653', + ], + 'marrom' => [ + 'nome' => 'Marrom', + 'css' => 'assets/css/identidade-visual-marrom.css', + 'favicon' => 'assets/img/logos/marrom/favicon.ico', + 'icone' => 'assets/img/logos/marrom/svg/icone-cor.svg', + 'cor' => '#A6754C', + ], + 'cinza' => [ + 'nome' => 'Cinza', + 'css' => 'assets/css/identidade-visual-cinza.css', + 'favicon' => 'assets/img/logos/cinza/favicon.ico', + 'icone' => 'assets/img/logos/cinza/svg/icone-cor.svg', + 'cor' => '#6B6F77', + ], +]; + +$ao_modos_info = [ + 'claro' => ['nome' => 'Claro', 'icone' => '☀️'], + 'escuro' => ['nome' => 'Escuro', 'icone' => '🌙'], +]; + +// Duração do cookie de identidade: 1 ano. Guardamos a escolha do usuário +// em cookie (não em sessão) justamente para que ela sobreviva ao fechar +// o navegador — é isso que "lembra" o tema/modo na próxima visita. +const AO_COOKIE_DURACAO = 60 * 60 * 24 * 365; + +function ao_definir_cookie($nome, $valor) { + setcookie($nome, $valor, [ + 'expires' => time() + AO_COOKIE_DURACAO, + 'path' => '/', + 'secure' => AO_IS_PRODUCAO, + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + +// Troca de tema/modo via link "?tema=marrom" e/ou "?modo=escuro" — grava +// em cookie e redireciona para a mesma URL sem o parâmetro, para manter +// o endereço limpo. Os dois podem vir juntos na mesma requisição. +$ao_precisa_redirecionar = false; + +if (isset($_GET['tema']) && array_key_exists($_GET['tema'], $ao_temas_info)) { + ao_definir_cookie('ao_tema', $_GET['tema']); + $_COOKIE['ao_tema'] = $_GET['tema']; + $ao_precisa_redirecionar = true; +} + +if (isset($_GET['modo']) && array_key_exists($_GET['modo'], $ao_modos_info)) { + ao_definir_cookie('ao_modo', $_GET['modo']); + $_COOKIE['ao_modo'] = $_GET['modo']; + $ao_precisa_redirecionar = true; +} + +if ($ao_precisa_redirecionar) { + $destino = strtok($_SERVER['REQUEST_URI'], '?'); + header('Location: ' . $destino); + exit; +} + +$ao_tema = $_COOKIE['ao_tema'] ?? 'verde'; +if (!array_key_exists($ao_tema, $ao_temas_info)) { + $ao_tema = 'verde'; +} + +$ao_modo = $_COOKIE['ao_modo'] ?? 'claro'; +if (!array_key_exists($ao_modo, $ao_modos_info)) { + $ao_modo = 'claro'; +} + +$ao_tema_atual = $ao_temas_info[$ao_tema]; +$ao_modo_atual = $ao_modos_info[$ao_modo]; +$ao_proximo_modo = $ao_modo === 'claro' ? 'escuro' : 'claro'; diff --git a/index.html b/index.html new file mode 100644 index 0000000..0d032ad --- /dev/null +++ b/index.html @@ -0,0 +1,89 @@ + + + + + +Ação e Opção Derivativos + + + +
+ Em breve +

Ação e Opção Derivativos

+

Nosso site está em fase final de preparação.

+

Volte em breve para conferir as novidades.

+
+

Dúvidas ou contato: contato@acaoeopcaoderivativos.systems

+
+ + diff --git a/index.php b/index.php new file mode 100644 index 0000000..6b354fc --- /dev/null +++ b/index.php @@ -0,0 +1,54 @@ + 'PETR4', 'qtd' => 300, 'preco_medio' => 36.10, 'atual' => 38.42, 'variacao' => 6.42], + ['ativo' => 'VALE3', 'qtd' => 150, 'preco_medio' => 63.80, 'atual' => 61.07, 'variacao' => -4.28], + ['ativo' => 'PETR4C40', 'qtd' => 1000, 'preco_medio' => 1.30, 'atual' => 1.12, 'variacao' => -13.85], +]; +?> + +

Minha carteira

+ +
+
+
+ Saldo consolidado + +3,2% no mês +
+

R$ 48.920,17

+
+
+
Em ações
+

R$ 36.150,00

+
+
+
Em opções
+

R$ 12.770,17

+
+
+ +
+
Posições abertas
+ + + + + + + = 0 ? 'ao-up' : 'ao-down'; ?> + + + + + + + + + +
AtivoQtd.Preço médioAtualResultado
R$ R$ = 0 ? '+' : '' ?>%
+
+ + diff --git a/login.php b/login.php new file mode 100644 index 0000000..3a61330 --- /dev/null +++ b/login.php @@ -0,0 +1,107 @@ +prepare('SELECT * FROM usuarios WHERE email = ? AND ativo = 1'); + $stmt->execute([$email]); + $usuarioDb = $stmt->fetch(); + + if ($usuarioDb && password_verify($senha, $usuarioDb['senha_hash'])) { + $stmtPerfil = $pdo->prepare('SELECT * FROM perfis WHERE id = ? AND ativo = 1'); + $stmtPerfil->execute([$usuarioDb['perfil_id']]); + $perfilDb = $stmtPerfil->fetch(); + + if (!$perfilDb) { + $erro = 'Seu perfil de acesso está inativo. Fale com um administrador.'; + } else { + ao_carregar_sessao_usuario($pdo, $usuarioDb, $perfilDb); + session_regenerate_id(true); + + $pdo->prepare('UPDATE usuarios SET ultimo_login = NOW() WHERE id = ?')->execute([$usuarioDb['id']]); + + $destino = $_SESSION['ao_pos_login'] ?? 'index.php'; + unset($_SESSION['ao_pos_login']); + header('Location: ' . $destino); + exit; + } + } else { + $erro = 'E-mail ou senha inválidos.'; + } +} +?> + + + + + +Entrar — AçãoOpção + + + + + + + + + + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..e06dcc7 --- /dev/null +++ b/logout.php @@ -0,0 +1,15 @@ +prepare('SELECT senha_hash FROM usuarios WHERE id = ?'); + $stmt->execute([$ao_usuario_id]); + $linha = $stmt->fetch(); + if (!$linha || !password_verify($senha_atual, $linha['senha_hash'])) { + $erro = 'Senha atual incorreta.'; + } + } + + if (!$erro) { + try { + if ($quer_trocar_senha) { + $stmt = $pdo->prepare('UPDATE usuarios SET nome = ?, senha_hash = ? WHERE id = ?'); + $stmt->execute([$nome, password_hash($nova_senha, PASSWORD_DEFAULT), $ao_usuario_id]); + } else { + $stmt = $pdo->prepare('UPDATE usuarios SET nome = ? WHERE id = ?'); + $stmt->execute([$nome, $ao_usuario_id]); + } + + // Atualiza a sessão para o nome novo aparecer no cabeçalho na hora. + $_SESSION['ao_usuario']['nome'] = $nome; + + header('Location: minha-conta.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = 'Erro ao salvar: ' . $e->getMessage(); + } + } + } +} + +if (isset($_GET['salvo'])) { + $sucesso = 'Dados salvos com sucesso.'; +} +// Nome exibido no formulário: o que acabou de ser digitado (se deu erro) ou o +// nome atual da sessão. +$ao_nome_form = $_SERVER['REQUEST_METHOD'] === 'POST' ? ($nome ?? $ao_usuario_atual['nome']) : $ao_usuario_atual['nome']; + +require __DIR__ . '/includes/header.php'; +?> + +

Minha conta

+ + +
+ + +
+ + +
+
Meus dados
+

+ E-mail: +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/operacoes.php b/operacoes.php new file mode 100644 index 0000000..d6e4ea5 --- /dev/null +++ b/operacoes.php @@ -0,0 +1,62 @@ + '20/08/2026', 'ativo' => 'PETR4', 'operacao' => 'Compra', 'qtd' => 100, 'preco' => 37.90], + ['data' => '18/08/2026', 'ativo' => 'PETR4C40', 'operacao' => 'Venda', 'qtd' => 500, 'preco' => 1.24], + ['data' => '12/08/2026', 'ativo' => 'VALE3', 'operacao' => 'Compra', 'qtd' => 150, 'preco' => 63.80], +]; +?> + +

Registrar operação

+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+
Histórico recente
+ + + + + + + + + + + + + + + +
DataAtivoOperaçãoQtd.Preço
R$
+
+ + diff --git a/paginas_admin.php b/paginas_admin.php new file mode 100644 index 0000000..5295925 --- /dev/null +++ b/paginas_admin.php @@ -0,0 +1,144 @@ +prepare('DELETE FROM paginas WHERE id = ?')->execute([$id]); + header('Location: paginas_admin.php?excluido=1'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $chave = strtolower(trim($_POST['chave'] ?? '')); + $nome = trim($_POST['nome'] ?? ''); + $arquivo = trim($_POST['arquivo'] ?? ''); + $ordem = (int) ($_POST['ordem'] ?? 0); + $ativo = isset($_POST['ativo']) ? 1 : 0; + + if ($chave === '' || $nome === '' || $arquivo === '') { + $erro = 'Preencha chave, nome e arquivo.'; + } elseif (!preg_match('/^[a-z0-9_]+$/', $chave)) { + $erro = 'A chave só pode ter letras minúsculas, números e sublinhado (ex: relatorios_mensais).'; + } elseif (array_key_exists($chave, AO_PAGINAS_ADMIN)) { + $erro = 'Essa chave é reservada para as páginas de administração do próprio sistema.'; + } else { + try { + if ($id > 0) { + $stmt = $pdo->prepare('UPDATE paginas SET chave=?, nome=?, arquivo=?, ordem=?, ativo=? WHERE id=?'); + $stmt->execute([$chave, $nome, $arquivo, $ordem, $ativo, $id]); + } else { + $stmt = $pdo->prepare('INSERT INTO paginas (chave, nome, arquivo, ordem, ativo) VALUES (?,?,?,?,?)'); + $stmt->execute([$chave, $nome, $arquivo, $ordem, $ativo]); + } + header('Location: paginas_admin.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = ($e->getCode() === '23000') ? 'Já existe uma página com esta chave.' : ('Erro ao salvar: ' . $e->getMessage()); + } + } +} + +$editando = null; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM paginas WHERE id = ?'); + $stmt->execute([(int) $_GET['editar']]); + $editando = $stmt->fetch() ?: null; +} + +if (isset($_GET['salvo'])) $sucesso = 'Página salva com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Página excluída.'; + +$paginas = $pdo->query('SELECT * FROM paginas ORDER BY ordem, nome')->fetchAll(); + +require __DIR__ . '/includes/header.php'; +?> + +

Páginas do sistema

+

+ Esta é a lista de páginas de negócio que podem ser liberadas por perfil, em Perfis. + Ao cadastrar uma página nova aqui, crie também o arquivo .php correspondente no projeto, + usando a mesma chave na variável $pagina_ativa. +

+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+ + + Cancelar + +
+
+ +
+
Páginas cadastradas
+ + + + + + + + + + + + + + + + +
OrdemNomeChaveArquivoStatus
+ Editar +
+ + + +
+
+
+ + diff --git a/perfis.php b/perfis.php new file mode 100644 index 0000000..13b67d2 --- /dev/null +++ b/perfis.php @@ -0,0 +1,167 @@ +prepare('SELECT COUNT(*) FROM usuarios WHERE perfil_id = ?'); + $stmt->execute([$id]); + if ((int) $stmt->fetchColumn() > 0) { + $erro = 'Não é possível excluir: existem usuários com este perfil.'; + } else { + $pdo->prepare('DELETE FROM perfis WHERE id = ?')->execute([$id]); + header('Location: perfis.php?excluido=1'); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $nome = trim($_POST['nome'] ?? ''); + $acesso_total = isset($_POST['acesso_total']) ? 1 : 0; + $ativo = isset($_POST['ativo']) ? 1 : 0; + $paginas_sel = array_map('intval', $_POST['paginas'] ?? []); + + if ($nome === '') { + $erro = 'Informe o nome do perfil.'; + } else { + try { + $pdo->beginTransaction(); + if ($id > 0) { + $stmt = $pdo->prepare('UPDATE perfis SET nome=?, acesso_total=?, ativo=? WHERE id=?'); + $stmt->execute([$nome, $acesso_total, $ativo, $id]); + } else { + $stmt = $pdo->prepare('INSERT INTO perfis (nome, acesso_total, ativo) VALUES (?,?,?)'); + $stmt->execute([$nome, $acesso_total, $ativo]); + $id = (int) $pdo->lastInsertId(); + } + + $pdo->prepare('DELETE FROM perfil_paginas WHERE perfil_id = ?')->execute([$id]); + if (!$acesso_total && $paginas_sel) { + $stmtIns = $pdo->prepare('INSERT INTO perfil_paginas (perfil_id, pagina_id) VALUES (?, ?)'); + foreach ($paginas_sel as $pid) { + $stmtIns->execute([$id, $pid]); + } + } + $pdo->commit(); + header('Location: perfis.php?salvo=1'); + exit; + } catch (PDOException $e) { + $pdo->rollBack(); + $erro = ($e->getCode() === '23000') ? 'Já existe um perfil com este nome.' : ('Erro ao salvar: ' . $e->getMessage()); + } + } +} + +$editando = null; +$paginas_do_perfil = []; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM perfis WHERE id = ?'); + $stmt->execute([(int) $_GET['editar']]); + $editando = $stmt->fetch() ?: null; + if ($editando) { + $stmt2 = $pdo->prepare('SELECT pagina_id FROM perfil_paginas WHERE perfil_id = ?'); + $stmt2->execute([$editando['id']]); + $paginas_do_perfil = array_map('intval', array_column($stmt2->fetchAll(), 'pagina_id')); + } +} + +if (isset($_GET['salvo'])) $sucesso = 'Perfil salvo com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Perfil excluído.'; + +$todas_paginas = $pdo->query('SELECT id, chave, nome FROM paginas WHERE ativo = 1 ORDER BY ordem, nome')->fetchAll(); +$perfis = $pdo->query( + 'SELECT pf.*, (SELECT COUNT(*) FROM usuarios u WHERE u.perfil_id = pf.id) AS total_usuarios FROM perfis pf ORDER BY pf.nome' +)->fetchAll(); + +require __DIR__ . '/includes/header.php'; +?> + +

Perfis de acesso

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

Nenhuma página cadastrada ainda — cadastre em Páginas.

+ + + + +
+ + + + + + Cancelar + +
+
+ +
+
Perfis cadastrados
+ + + + + + + + + + + + + + + +
NomeAcessoUsuáriosStatus
Total' : 'Configurado' ?> + Editar + +
+ + + +
+ +
+
+ + diff --git a/phpinfo.php b/phpinfo.php new file mode 100644 index 0000000..61ace19 --- /dev/null +++ b/phpinfo.php @@ -0,0 +1,2 @@ + + + + + +Diagnóstico do servidor + + + + +
+

1. Teste de conexão com o banco de dados

+ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + echo "

✔ Conexão bem-sucedida com o host '$host'.

"; + + $version = $pdo->query("SELECT VERSION() AS v")->fetch(PDO::FETCH_ASSOC); + echo "

Versão do servidor de banco: " . htmlspecialchars($version['v']) . "

"; + + $dbs = $pdo->query("SHOW DATABASES")->fetchAll(PDO::FETCH_COLUMN); + echo "

Bancos de dados disponíveis:

    "; + foreach ($dbs as $db) { + echo "
  • " . htmlspecialchars($db) . "
  • "; + } + echo "
"; + } catch (PDOException $e) { + echo "

✘ Falha na conexão: " . htmlspecialchars($e->getMessage()) . "

"; + } + ?> +
+ +
+

2. Informações resumidas do PHP

+ + phpversion(), + 'Servidor (SAPI)' => php_sapi_name(), + 'Sistema operacional' => php_uname(), + 'Document root' => $_SERVER['DOCUMENT_ROOT'] ?? '-', + 'Extensão pdo_mysql ativa' => extension_loaded('pdo_mysql') ? 'sim' : 'não', + 'Extensão mysqli ativa' => extension_loaded('mysqli') ? 'sim' : 'não', + 'Limite de memória' => ini_get('memory_limit'), + 'Upload máximo' => ini_get('upload_max_filesize'), + 'Post máximo' => ini_get('post_max_size'), + 'Timezone' => date_default_timezone_get(), + ]; + foreach ($rows as $k => $v) { + echo ""; + } + ?> +
" . htmlspecialchars($k) . "" . htmlspecialchars((string)$v) . "
+
+ +
+

3. Configurações completas do PHP

+

O phpinfo() tem estilo próprio que bagunça o layout desta página, então ele fica isolado em outra página:

+ Abrir phpinfo() completo +
+ + + diff --git a/usuarios.php b/usuarios.php new file mode 100644 index 0000000..f4b5b3c --- /dev/null +++ b/usuarios.php @@ -0,0 +1,313 @@ + 'u.nome', + 'email' => 'u.email', + 'perfil' => 'p.nome', + 'status' => 'u.ativo', + 'ultimo_login' => 'u.ultimo_login', +]; +const AO_USUARIOS_POR_PAGINA = 10; + +$erro = null; +$sucesso = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'excluir') { + $id = (int) ($_POST['id'] ?? 0); + if ($id === (int) $ao_usuario_atual['id']) { + $erro = 'Você não pode excluir o próprio usuário enquanto estiver logado com ele.'; + } else { + $pdo->prepare('DELETE FROM usuarios WHERE id = ?')->execute([$id]); + header('Location: usuarios.php?excluido=1'); + exit; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar') { + $id = (int) ($_POST['id'] ?? 0); + $nome = trim($_POST['nome'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $senha = (string) ($_POST['senha'] ?? ''); + $confirmar_senha = (string) ($_POST['confirmar_senha'] ?? ''); + $perfil_id = (int) ($_POST['perfil_id'] ?? 0); + $ativo = isset($_POST['ativo']) ? 1 : 0; + + if ($nome === '' || $email === '' || $perfil_id === 0) { + $erro = 'Preencha nome, e-mail e perfil.'; + } elseif ($id === 0 && $senha === '') { + $erro = 'Informe uma senha para o novo usuário.'; + } elseif ($senha !== '' && $senha !== $confirmar_senha) { + $erro = 'A senha e a confirmação de senha não são iguais.'; + } else { + try { + if ($id > 0) { + if ($senha !== '') { + $stmt = $pdo->prepare('UPDATE usuarios SET nome=?, email=?, senha_hash=?, perfil_id=?, ativo=? WHERE id=?'); + $stmt->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), $perfil_id, $ativo, $id]); + } else { + $stmt = $pdo->prepare('UPDATE usuarios SET nome=?, email=?, perfil_id=?, ativo=? WHERE id=?'); + $stmt->execute([$nome, $email, $perfil_id, $ativo, $id]); + } + } else { + $stmt = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, perfil_id, ativo) VALUES (?,?,?,?,?)'); + $stmt->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), $perfil_id, $ativo]); + } + header('Location: usuarios.php?salvo=1'); + exit; + } catch (PDOException $e) { + $erro = ($e->getCode() === '23000') ? 'Já existe um usuário com este e-mail.' : ('Erro ao salvar: ' . $e->getMessage()); + } + } +} + +$editando = null; +if (isset($_GET['editar'])) { + $stmt = $pdo->prepare('SELECT * FROM usuarios WHERE id = ?'); + $stmt->execute([(int) $_GET['editar']]); + $editando = $stmt->fetch() ?: null; +} + +// Se o formulário foi enviado e deu erro, mantemos os dados digitados na +// tela (menos as senhas, por segurança) em vez de limpar tudo. +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['acao'] ?? '') === 'salvar' && $erro) { + $editando = [ + 'id' => $id, + 'nome' => $nome, + 'email' => $email, + 'perfil_id' => $perfil_id, + 'ativo' => $ativo, + ]; +} + +if (isset($_GET['salvo'])) $sucesso = 'Usuário salvo com sucesso.'; +if (isset($_GET['excluido'])) $sucesso = 'Usuário excluído.'; + +$perfis = $pdo->query('SELECT id, nome FROM perfis WHERE ativo = 1 ORDER BY nome')->fetchAll(); + +// Ordenação do grid: valida contra a lista fixa acima antes de montar o SQL. +$ao_ordenar = (string) ($_GET['ordenar'] ?? 'nome'); +if (!array_key_exists($ao_ordenar, AO_USUARIOS_COLUNAS_ORDENAVEIS)) { + $ao_ordenar = 'nome'; +} +$ao_direcao = strtolower((string) ($_GET['direcao'] ?? 'asc')) === 'desc' ? 'desc' : 'asc'; +$ao_ordenar_sql = AO_USUARIOS_COLUNAS_ORDENAVEIS[$ao_ordenar] . ' ' . strtoupper($ao_direcao); + +// Paginação do grid. +$ao_total_usuarios = (int) $pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn(); +$ao_total_paginas = max(1, (int) ceil($ao_total_usuarios / AO_USUARIOS_POR_PAGINA)); +$ao_pagina_atual = (int) ($_GET['pagina'] ?? 1); +if ($ao_pagina_atual < 1) $ao_pagina_atual = 1; +if ($ao_pagina_atual > $ao_total_paginas) $ao_pagina_atual = $ao_total_paginas; +$ao_offset = ($ao_pagina_atual - 1) * AO_USUARIOS_POR_PAGINA; + +$usuarios = $pdo->query( + "SELECT u.*, p.nome AS perfil_nome FROM usuarios u INNER JOIN perfis p ON p.id = u.perfil_id + ORDER BY {$ao_ordenar_sql} + LIMIT " . AO_USUARIOS_POR_PAGINA . " OFFSET {$ao_offset}" +)->fetchAll(); + +/** Monta a URL do grid preservando ordenação/página, trocando só o que for passado em $sobrescrever. */ +function ao_usuarios_url(array $sobrescrever, $ordenar, $direcao, $pagina) { + $params = array_merge(['ordenar' => $ordenar, 'direcao' => $direcao, 'pagina' => $pagina], $sobrescrever); + return 'usuarios.php?' . http_build_query($params); +} + +require __DIR__ . '/includes/header.php'; +?> + +
+

Usuários

+ +
+ + +
+ + +
+ + +
+ +
+ +
+
Usuários cadastrados
+ + + + 'Nome', + 'email' => 'E-mail', + 'perfil' => 'Perfil', + 'status' => 'Status', + 'ultimo_login' => 'Último login', + ]; + ?> + $ao_col_rotulo): ?> + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + Nunca + + + Editar + +
+ + + +
+ +
+ + 1): ?> +
+ + Página de ( usuários) + + +
+ +
+ + + + + +