Crie um sistema completo em PHP + MySQL para um SaaS de agendamento online focado em barbearias.Requisitos Gerais:Estrutura escalável em PHP (utilizando padrão MVC) com conexão MySQL.O sistema será multi-estabelecimento (cada barbearia terá seu próprio painel).Autenticação segura de usuários (bcrypt para senhas e JWT ou sessão PHP para login).Separação de permissões: administrador da barbearia, barbeiro/profissional e cliente.Painel administrativo para cada barbearia gerenciar:Cadastro de serviços (ex: corte, barba, sobrancelha) com preço e duração.Cadastro de barbeiros/profissionais com foto, especialidades e agenda.Configuração de horários de funcionamento e dias de folga.Cadastro e gerenciamento de clientes.Visualização e controle de agendamentos.Painel do profissional para visualizar e confirmar/cancelar seus próprios agendamentos.Painel do cliente com histórico de agendamentos e opção de marcar novos horários.Funcionalidades de Agendamento:Seleção de serviço, barbeiro e horário disponível (checando conflitos automaticamente).Sistema de confirmação por e-mail e/ou WhatsApp (com estrutura pronta para integração via API).Bloqueio automático de horários já reservados.Reagendamento e cancelamento com política configurável.Funcionalidades Extras:Relatórios: total de atendimentos, faturamento por período, serviços mais vendidos.Opção de adicionar descontos e promoções.Controle de assinaturas do SaaS (planos com limite de profissionais e agendamentos).Banco de Dados (MySQL):Tabelas sugeridas:users (dados de login, tipo de usuário)barbearias (informações da barbearia)servicos (nome, duração, preço, barbearia_id)profissionais (dados, especialidades, barbearia_id)clientes (dados do cliente)agendamentos (serviço_id, profissional_id, cliente_id, data, hora, status)planos (nome, preço, limites)assinaturas (barbearia_id, plano_id, status, validade)Tecnologias e Boas Práticas:PHP 8+, PDO para acesso ao MySQL com prepared statements.HTML5 + CSS3 + JavaScript para frontend básico (pode usar Bootstrap para responsividade).Código comentado e organizado para manutenção.Estrutura pronta para deploy em hospedagem compartilhada ou VPS.Entrega Esperada:Código-fonte completo (PHP + HTML/CSS/JS).Script SQL para criação do banco de dados.Documentação básica explicando instalação e configuração.
# BarberSaaS — Sistema de Agendamento para Barbearias (PHP + MySQL)
Sistema SaaS multi-estabelecimento para agendamentos de barbearias, com autenticação, permissões, painel administrativo, profissionais, clientes, serviços, horários de funcionamento, folgas, agendamentos, relatórios e controle de assinaturas.
Tecnologias:
- PHP 8+
- MySQL 8+ (ou 5.7+)
- PDO com prepared statements
- Bootstrap 5 (CDN)
- Sessões PHP (password_hash/password_verify)
- Padrão MVC simples
## Requisitos
- PHP 8.1+ com extensões: pdo_mysql, mbstring, gd (para uploads de imagens)
- MySQL
- Servidor Apache com mod_rewrite (ou Nginx com rewrites equivalentes)
## Instalação
1) Crie um banco de dados MySQL.
2) Importe o arquivo `database/schema.sql`.
3) Copie o projeto para o seu servidor (pasta pública aponta para `public/`).
4) Edite `app/config/config.php` com suas credenciais do banco e URL base.
5) Defina permissões de escrita para as pastas:
- `public/uploads/`
- `storage/logs/`
6) Acesse `/register` para criar sua barbearia e o administrador.
7) Faça login em `/login`.
Usuários e papéis:
- admin: administrador da barbearia (gerencia tudo)
- profissional: vê/gerencia apenas seus próprios agendamentos
- cliente: agenda, vê histórico
## Estrutura de Pastas
- public/ — front controller, .htaccess, assets
- app/
- config/ — configurações
- core/ — núcleo MVC (Router, Controller, Model, Database, Auth, RBAC, Helpers)
- controllers/ — controladores
- models/ — modelos
- services/ — serviços (Notificação, Assinatura)
- views/ — templates
- database/ — schema SQL
- scripts/ — cron para assinaturas
- storage/logs/ — logs e debug
## Funcionalidades
- Multi-tenant por `barbearia_id`
- Autenticação segura com sessões e bcrypt
- RBAC: admin, profissional, cliente
- Serviços: CRUD, preço, duração
- Profissionais: CRUD, upload de foto, especialidades, agenda semanal
- Clientes: CRUD
- Configuração de horários de funcionamento e folgas
- Agendamentos: seleção de serviço, profissional, horário disponível
- Checagem de conflito e disponibilidade (inclui horários/folgas)
- Confirmação/cancelamento/reagendamento
- Notificação (estrutura pronta para e-mail/WhatsApp)
- Relatórios: atendimentos, faturamento, serviços mais vendidos
- Assinaturas: planos com limites de profissionais e agendamentos/mês, status, validade
- Políticas de cancelamento/reagendamento (básicas, ajustáveis em config)
## Variáveis de Configuração
Edite `app/config/config.php`:
- DB_DSN, DB_USER, DB_PASS
- APP_URL, APP_NAME
- TIMEZONE
- MAIL_FROM, WHATSAPP_API_URL (stubs)
- CANCELLATION_POLICY_HOURS — limite para cancelamento
- RESCHEDULE_POLICY_HOURS — limite para reagendamento
## Cron (Assinaturas)
Configure um cron job (ex.: a cada hora) para:
\`\`\`
php scripts/cron/check-subscriptions.php
\`\`\`
Ele atualiza assinaturas expiradas.
## Segurança
- CSRF token em todos os formulários POST
- Prepared statements
- Escapando saída com `e()` helper
- Upload seguro (valida mimetype e tamanho)
- Sessões seguras (httponly, use_strict_mode)
## Deploy
- Hospedagem compartilhada: certifique-se do `.htaccess` e PHP 8.1+
- VPS: configure Apache/Nginx para apontar para `public/` como docroot
- Ajuste permissões de escrita
## Suporte
- Logs em `storage/logs/app.log`
- Você pode estender os serviços de E-mail e WhatsApp em `app/services/NotificationService.php`
\`\`\`
/* BarberSaaS - Schema SQL */
SET NAMES utf8mb4;
SET time_zone = '+00:00';
CREATE TABLE IF NOT EXISTS barbearias (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150),
telefone VARCHAR(50),
endereco VARCHAR(255),
cidade VARCHAR(100),
estado VARCHAR(50),
cep VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NULL,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
senha VARCHAR(255) NOT NULL,
role ENUM('admin','profissional','cliente') NOT NULL,
ativo TINYINT(1) DEFAULT 1,
last_login DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS clientes (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
user_id INT NULL,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150),
telefone VARCHAR(50),
obs TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX (barbearia_id),
INDEX (email)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS profissionais (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
user_id INT NULL,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150),
telefone VARCHAR(50),
especialidades VARCHAR(255),
foto VARCHAR(255),
ativo TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX (barbearia_id),
INDEX (nome)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS servicos (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
nome VARCHAR(150) NOT NULL,
duracao_min INT NOT NULL,
preco DECIMAL(10,2) NOT NULL DEFAULT 0.00,
ativo TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
UNIQUE KEY uniq_servico_nome (barbearia_id, nome),
INDEX (barbearia_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS horarios_funcionamento (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
weekday TINYINT NOT NULL, /* 0=Dom, 6=Sab */
abre TIME NULL,
fecha TIME NULL,
fechado TINYINT(1) DEFAULT 0,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
UNIQUE KEY uniq_func (barbearia_id, weekday)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS profissionais_horarios (
id INT AUTO_INCREMENT PRIMARY KEY,
profissional_id INT NOT NULL,
weekday TINYINT NOT NULL,
inicia TIME NULL,
termina TIME NULL,
folga TINYINT(1) DEFAULT 0,
FOREIGN KEY (profissional_id) REFERENCES profissionais(id) ON DELETE CASCADE,
UNIQUE KEY uniq_prof_func (profissional_id, weekday)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS folgas (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
profissional_id INT NULL,
data DATE NOT NULL,
motivo VARCHAR(255),
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (profissional_id) REFERENCES profissionais(id) ON DELETE CASCADE,
INDEX (barbearia_id, data),
INDEX (profissional_id, data)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS agendamentos (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
servico_id INT NOT NULL,
profissional_id INT NOT NULL,
cliente_id INT NOT NULL,
inicio DATETIME NOT NULL,
fim DATETIME NOT NULL,
preco DECIMAL(10,2) NOT NULL,
status ENUM('pendente','confirmado','cancelado','concluido','nao_compareceu','remarcado') NOT NULL DEFAULT 'pendente',
notas TEXT,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (servico_id) REFERENCES servicos(id) ON DELETE RESTRICT,
FOREIGN KEY (profissional_id) REFERENCES profissionais(id) ON DELETE RESTRICT,
FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE RESTRICT,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX (barbearia_id, profissional_id, inicio),
INDEX (cliente_id, inicio)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS promocoes (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
nome VARCHAR(150) NOT NULL,
tipo ENUM('percent','fixed') NOT NULL,
valor DECIMAL(10,2) NOT NULL,
servico_id INT NULL,
inicio DATE NOT NULL,
fim DATE NOT NULL,
ativo TINYINT(1) DEFAULT 1,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (servico_id) REFERENCES servicos(id) ON DELETE SET NULL,
INDEX (barbearia_id, ativo)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS planos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
preco DECIMAL(10,2) NOT NULL DEFAULT 0.00,
limite_profissionais INT NOT NULL DEFAULT 1,
limite_agendamentos_mensal INT NOT NULL DEFAULT 100,
ativo TINYINT(1) DEFAULT 1
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS assinaturas (
id INT AUTO_INCREMENT PRIMARY KEY,
barbearia_id INT NOT NULL,
plano_id INT NOT NULL,
status ENUM('ativa','suspensa','expirada') NOT NULL DEFAULT 'ativa',
inicio DATE NOT NULL,
validade DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (barbearia_id) REFERENCES barbearias(id) ON DELETE CASCADE,
FOREIGN KEY (plano_id) REFERENCES planos(id) ON DELETE RESTRICT,
INDEX (barbearia_id),
INDEX (status, validade)
) ENGINE=InnoDB;
/* Dados iniciais */
INSERT INTO planos (nome, preco, limite_profissionais, limite_agendamentos_mensal, ativo) VALUES
('Starter', 59.90, 2, 200, 1),
('Pro', 129.90, 5, 800, 1),
('Unlimited', 249.90, 999, 999999, 1);
RewriteEngine On
# Enforce HTTPS (optional, uncomment if you have SSL)
# RewriteCond %{HTTPS} !=on
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
RewriteBase /
# Serve existing files directly
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Route all requests to index.php
RewriteRule ^ index.php [QSA,L]
<?php
declare(strict_types=1);
use App\Core\Router;
require_once __DIR__ . '/../vendor/autoload.php'; // If you later add Composer. Optional.
require_once __DIR__ . '/../app/bootstrap.php';
$router = new Router();
/* Public routes */
$router->get('/', 'DashboardController@index');
$router->get('/login', 'AuthController@login');
$router->post('/login', 'AuthController@loginPost');
$router->get('/logout', 'AuthController@logout');
$router->get('/register', 'AuthController@register');
$router->post('/register', 'AuthController@registerPost');
/* Auth-protected routes */
$router->get('/dashboard', 'DashboardController@index');
/* Serviços */
$router->get('/servicos', 'ServicosController@index');
$router->get('/servicos/novo', 'ServicosController@create');
$router->post('/servicos/novo', 'ServicosController@store');
$router->get('/servicos/editar/{id}', 'ServicosController@edit');
$router->post('/servicos/editar/{id}', 'ServicosController@update');
$router->post('/servicos/delete/{id}', 'ServicosController@destroy');
/* Profissionais */
$router->get('/profissionais', 'ProfissionaisController@index');
$router->get('/profissionais/novo', 'ProfissionaisController@create');
$router->post('/profissionais/novo', 'ProfissionaisController@store');
$router->get('/profissionais/editar/{id}', 'ProfissionaisController@edit');
$router->post('/profissionais/editar/{id}', 'ProfissionaisController@update');
$router->post('/profissionais/delete/{id}', 'ProfissionaisController@destroy');
/* Clientes */
$router->get('/clientes', 'ClientesController@index');
$router->get('/clientes/novo', 'ClientesController@create');
$router->post('/clientes/novo', 'ClientesController@store');
$router->get('/clientes/editar/{id}', 'ClientesController@edit');
$router->post('/clientes/editar/{id}', 'ClientesController@update');
$router->post('/clientes/delete/{id}', 'ClientesController@destroy');
/* Agendamentos */
$router->get('/agendamentos', 'AgendamentosController@index');
$router->get('/agendamentos/novo', 'AgendamentosController@create');
$router->post('/agendamentos/novo', 'AgendamentosController@store');
$router->post('/agendamentos/confirmar/{id}', 'AgendamentosController@confirm');
$router->post('/agendamentos/cancelar/{id}', 'AgendamentosController@cancel');
$router->get('/agendamentos/remarcar/{id}', 'AgendamentosController@rescheduleForm');
$router->post('/agendamentos/remarcar/{id}', 'AgendamentosController@reschedule');
/* Relatórios */
$router->get('/relatorios', 'RelatoriosController@index');
/* Assinaturas */
$router->get('/assinaturas', 'AssinaturasController@index');
$router->post('/assinaturas/ativar', 'AssinaturasController@activate');
/* Configurações da Barbearia */
$router->get('/configuracoes', 'BarbeariaController@index');
$router->post('/configuracoes/horarios', 'BarbeariaController@saveHorarios');
$router->post('/configuracoes/folgas', 'BarbeariaController@saveFolgas');
$router->dispatch();
<?php
declare(strict_types=1);
session_start([
'cookie_httponly' => true,
'cookie_secure' => isset($_SERVER['HTTPS']),
'use_strict_mode' => true,
'sid_length' => 48,
]);
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/core/Helpers.php';
require_once __DIR__ . '/core/Autoload.php';
date_default_timezone_set(TIMEZONE);
<?php
declare(strict_types=1);
/* Ajuste para o seu ambiente */
const DB_DSN = 'mysql:host=localhost;dbname=barbersaas;charset=utf8mb4';
const DB_USER = 'root';
const DB_PASS = '';
const APP_NAME = 'BarberSaaS';
const APP_URL = 'http://localhost'; // sem barra no final
const TIMEZONE = 'America/Sao_Paulo';
/* E-mail/WhatsApp (stubs) */
const MAIL_FROM = 'no-reply@seusite.com';
const WHATSAPP_API_URL = ''; // Ex.: https://api.whatsapp.example/send
/* Políticas */
const CANCELLATION_POLICY_HOURS = 2; // sem multa até 2h antes
const RESCHEDULE_POLICY_HOURS = 2;
/* Segurança */
const CSRF_TOKEN_KEY = '_csrf';
const CSRF_TTL_SECONDS = 3600;
/* Uploads */
const UPLOAD_DIR = __DIR__ . '/../../public/uploads';
const MAX_UPLOAD_MB = 5;
<?php
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$base_dir = __DIR__ . '/../';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require $file;
}
});
<?php
namespace App\Core;
class Router
{
private array $routes = [];
public function get(string $path, string $handler): void {
$this->addRoute('GET', $path, $handler);
}
public function post(string $path, string $handler): void {
$this->addRoute('POST', $path, $handler);
}
private function addRoute(string $method, string $path, string $handler): void {
$path = rtrim($path, '/') ?: '/';
$this->routes[$method][$path] = $handler;
}
public function dispatch(): void {
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$uri = rtrim($uri, '/') ?: '/';
[$handler, $params] = $this->match($method, $uri);
if (!$handler) {
http_response_code(404);
echo '404 Not Found';
return;
}
[$controllerName, $action] = explode('@', $handler);
$controllerClass = "App\\Controllers\\$controllerName";
if (!class_exists($controllerClass)) {
http_response_code(500);
echo 'Controller not found';
return;
}
$controller = new $controllerClass();
if (!method_exists($controller, $action)) {
http_response_code(500);
echo 'Action not found';
return;
}
call_user_func_array([$controller, $action], $params);
}
private function match(string $method, string $uri): array {
$routes = $this->routes[$method] ?? [];
if (isset($routes[$uri])) {
return [$routes[$uri], []];
}
// Match dynamic segments like /path/{id}
foreach ($routes as $path => $handler) {
$pattern = preg_replace('#\{[^/]+\}#', '([^/]+)', $path);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches);
return [$handler, $matches];
}
}
return [null, []];
}
}
<?php
namespace App\Core;
use PDO;
use PDOException;
class Database
{
private static ?PDO $conn = null;
public static function conn(): PDO {
if (self::$conn === null) {
try {
self::$conn = new PDO(DB_DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
self::log('DB Connection error: ' . $e->getMessage());
http_response_code(500);
exit('Erro de conexão com o banco.');
}
}
return self::$conn;
}
private static function log(string $message): void {
$dir = __DIR__ . '/../../storage/logs';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
error_log('[' . date('c') . "] $message\n", 3, $dir . '/app.log');
}
}
<?php
namespace App\Core;
abstract class Model
{
protected \PDO $db;
public function __construct()
{
$this->db = Database::conn();
}
}
<?php
namespace App\Core;
use App\Core\Auth;
abstract class Controller
{
protected function render(string $view, array $params = [], ?string $layout = 'main'): void {
extract($params, EXTR_SKIP);
$viewFile = __DIR__ . '/../views/' . $view . '.php';
if (!file_exists($viewFile)) {
http_response_code(500);
exit('View não encontrada');
}
if ($layout) {
$layoutFile = __DIR__ . '/../views/layouts/' . $layout . '.php';
if (!file_exists($layoutFile)) {
http_response_code(500);
exit('Layout não encontrado');
}
ob_start();
require $viewFile;
$content = ob_get_clean();
require $layoutFile;
} else {
require $viewFile;
}
}
protected function redirect(string $path): void {
header('Location: ' . $this->url($path));
exit;
}
protected function url(string $path): string {
$path = '/' . ltrim($path, '/');
return APP_URL . $path;
}
protected function requireAuth(array $roles = []): void {
if (!Auth::check()) {
$_SESSION['flash_error'] = 'Faça login para continuar.';
$this->redirect('/login');
}
if ($roles && !Auth::hasAnyRole($roles)) {
http_response_code(403);
exit('Acesso negado');
}
}
protected function csrf(): void {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST[CSRF_TOKEN_KEY] ?? '';
if (!\App\Core\Auth::verifyCsrf($token)) {
http_response_code(400);
exit('Token CSRF inválido.');
}
}
}
}
<?php
namespace App\Core;
use PDO;
class Auth
{
public static function user(): ?array {
return $_SESSION['user'] ?? null;
}
public static function check(): bool {
return isset($_SESSION['user']);
}
public static function login(array $user): void {
$_SESSION['user'] = [
'id' => (int)$user['id'],
'barbearia_id' => $user['barbearia_id'] ? (int)$user['barbearia_id'] : null,
'nome' => $user['nome'],
'email' => $user['email'],
'role' => $user['role'],
];
session_regenerate_id(true);
}
public static function logout(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'] ?? '', $params['secure'] ?? false, $params['httponly'] ?? true);
}
session_destroy();
}
public static function hasRole(string $role): bool {
return self::check() && ($_SESSION['user']['role'] ?? null) === $role;
}
public static function hasAnyRole(array $roles): bool {
return self::check() && in_array($_SESSION['user']['role'] ?? null, $roles, true);
}
public static function csrfToken(): string {
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_exp'] = time() + CSRF_TTL_SECONDS;
return $token;
}
public static function verifyCsrf(string $token): bool {
$t = $_SESSION['csrf_token'] ?? '';
$exp = $_SESSION['csrf_exp'] ?? 0;
return hash_equals((string)$t, (string)$token) && time() <= (int)$exp;
}
}
<?php
namespace App\Core;
class RBAC
{
public static function canManageBarbearia(): bool {
return Auth::hasRole('admin');
}
public static function canManageOwnAppointments(): bool {
return Auth::hasRole('profissional');
}
public static function canBook(): bool {
return Auth::hasRole('cliente') || Auth::hasRole('admin');
}
}
<?php
namespace App\Core;
function e(?string $s): string {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function input(string $key, $default = null) {
return $_POST[$key] ?? $_GET[$key] ?? $default;
}
function flash(string $key): ?string {
$msg = $_SESSION["flash_$key"] ?? null;
unset($_SESSION["flash_$key"]);
return $msg;
}
function set_flash(string $key, string $message): void {
$_SESSION["flash_$key"] = $message;
}
function parse_time_to_minutes(string $time): int {
[$h, $m] = explode(':', $time);
return ((int)$h) * 60 + (int)$m;
}
function ensure_dir(string $dir): void {
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
}
function format_money(float $v): string {
return 'R$ ' . number_format($v, 2, ',', '.');
}
<?php
namespace App\Services;
use App\Core\Database;
class NotificationService
{
public static function sendEmail(string $to, string $subject, string $html): bool {
// Stub simples usando mail(). Substitua por PHPMailer/SMTP em produção.
$headers = "From: " . MAIL_FROM . "\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
$ok = @mail($to, $subject, $html, $headers);
self::log('email', json_encode(compact('to','subject','ok')));
return $ok;
}
public static function sendWhatsApp(string $phone, string $message): bool {
// Stub para API externa. Integre com seu provedor.
// file_get_contents(WHATSAPP_API_URL . '?to=' . urlencode($phone) . '&msg=' . urlencode($message));
self::log('whatsapp', json_encode(compact('phone','message')));
return true;
}
private static function log(string $channel, string $msg): void {
$dir = __DIR__ . '/../../storage/logs';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
error_log('['.date('c')."] [$channel] $msg\n", 3, $dir.'/notifications.log');
}
}
<?php
namespace App\Services;
use App\Core\Database;
use PDO;
class SubscriptionService
{
public static function getActiveSubscription(int $barbeariaId): ?array {
$db = Database::conn();
$stmt = $db->prepare("SELECT a.*, p.nome as plano_nome, p.limite_profissionais, p.limite_agendamentos_mensal
FROM assinaturas a
JOIN planos p ON p.id = a.plano_id
WHERE a.barbearia_id = :bid AND a.status = 'ativa' AND a.validade >= CURDATE()
ORDER BY a.validade DESC LIMIT 1");
$stmt->execute([':bid' => $barbeariaId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public static function canAddProfessional(int $barbeariaId): bool {
$sub = self::getActiveSubscription($barbeariaId);
if (!$sub) return false;
$db = Database::conn();
$stmt = $db->prepare("SELECT COUNT(*) FROM profissionais WHERE barbearia_id = :bid AND ativo = 1");
$stmt->execute([':bid' => $barbeariaId]);
$count = (int)$stmt->fetchColumn();
return $count < (int)$sub['limite_profissionais'];
}
public static function canCreateAppointment(int $barbeariaId): bool {
$sub = self::getActiveSubscription($barbeariaId);
if (!$sub) return false;
$db = Database::conn();
$stmt = $db->prepare("SELECT COUNT(*) FROM agendamentos
WHERE barbearia_id = :bid AND MONTH(inicio) = MONTH(CURDATE()) AND YEAR(inicio) = YEAR(CURDATE())");
$stmt->execute([':bid' => $barbeariaId]);
$count = (int)$stmt->fetchColumn();
return $count < (int)$sub['limite_agendamentos_mensal'];
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class Barbearia extends Model
{
public function create(array $data): int {
$stmt = $this->db->prepare("INSERT INTO barbearias (nome, email, telefone, endereco, cidade, estado, cep)
VALUES (:nome, :email, :telefone, :endereco, :cidade, :estado, :cep)");
$stmt->execute([
':nome' => $data['nome'],
':email' => $data['email'] ?? null,
':telefone' => $data['telefone'] ?? null,
':endereco' => $data['endereco'] ?? null,
':cidade' => $data['cidade'] ?? null,
':estado' => $data['estado'] ?? null,
':cep' => $data['cep'] ?? null,
]);
$id = (int)$this->db->lastInsertId();
// Inicializa horários de funcionamento padrão (Seg-Sab 09:00-19:00, Dom fechado)
$stmt2 = $this->db->prepare("INSERT INTO horarios_funcionamento (barbearia_id, weekday, abre, fecha, fechado) VALUES
(:bid,0,NULL,NULL,1),(:bid,1,'09:00','19:00',0),(:bid,2,'09:00','19:00',0),(:bid,3,'09:00','19:00',0),
(:bid,4,'09:00','19:00',0),(:bid,5,'09:00','19:00',0),(:bid,6,'09:00','17:00',0)");
$stmt2->execute([':bid' => $id]);
return $id;
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class User extends Model
{
public function findByEmail(string $email): ?array {
$stmt = $this->db->prepare("SELECT * FROM users WHERE email = :email AND ativo = 1");
$stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(array $data): int {
$stmt = $this->db->prepare("INSERT INTO users (barbearia_id, nome, email, senha, role)
VALUES (:barbearia_id, :nome, :email, :senha, :role)");
$stmt->execute([
':barbearia_id' => $data['barbearia_id'] ?? null,
':nome' => $data['nome'],
':email' => $data['email'],
':senha' => password_hash($data['senha'], PASSWORD_BCRYPT),
':role' => $data['role'],
]);
return (int)$this->db->lastInsertId();
}
public function updateLastLogin(int $id): void {
$stmt = $this->db->prepare("UPDATE users SET last_login = NOW() WHERE id = :id");
$stmt->execute([':id' => $id]);
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class Servico extends Model
{
public function all(int $barbeariaId): array {
$stmt = $this->db->prepare("SELECT * FROM servicos WHERE barbearia_id = :bid ORDER BY nome");
$stmt->execute([':bid' => $barbeariaId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function find(int $id, int $barbeariaId): ?array {
$stmt = $this->db->prepare("SELECT * FROM servicos WHERE id = :id AND barbearia_id = :bid");
$stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(int $barbeariaId, array $data): int {
$stmt = $this->db->prepare("INSERT INTO servicos (barbearia_id, nome, duracao_min, preco, ativo)
VALUES (:bid, :nome, :duracao_min, :preco, :ativo)");
$stmt->execute([
':bid' => $barbeariaId,
':nome' => $data['nome'],
':duracao_min' => (int)$data['duracao_min'],
':preco' => (float)$data['preco'],
':ativo' => isset($data['ativo']) ? 1 : 0,
]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, int $barbeariaId, array $data): bool {
$stmt = $this->db->prepare("UPDATE servicos SET nome=:nome, duracao_min=:duracao_min, preco=:preco, ativo=:ativo
WHERE id=:id AND barbearia_id = :bid");
return $stmt->execute([
':nome' => $data['nome'],
':duracao_min' => (int)$data['duracao_min'],
':preco' => (float)$data['preco'],
':ativo' => isset($data['ativo']) ? 1 : 0,
':id' => $id,
':bid' => $barbeariaId,
]);
}
public function delete(int $id, int $barbeariaId): bool {
$stmt = $this->db->prepare("DELETE FROM servicos WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class Profissional extends Model
{
public function all(int $barbeariaId): array {
$stmt = $this->db->prepare("SELECT * FROM profissionais WHERE barbearia_id = :bid ORDER BY nome");
$stmt->execute([':bid' => $barbeariaId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function find(int $id, int $barbeariaId): ?array {
$stmt = $this->db->prepare("SELECT * FROM profissionais WHERE id = :id AND barbearia_id = :bid");
$stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(int $barbeariaId, array $data): int {
$stmt = $this->db->prepare("INSERT INTO profissionais (barbearia_id, user_id, nome, email, telefone, especialidades, foto, ativo)
VALUES (:bid, :user_id, :nome, :email, :telefone, :especialidades, :foto, :ativo)");
$stmt->execute([
':bid' => $barbeariaId,
':user_id' => $data['user_id'] ?? null,
':nome' => $data['nome'],
':email' => $data['email'] ?? null,
':telefone' => $data['telefone'] ?? null,
':especialidades' => $data['especialidades'] ?? null,
':foto' => $data['foto'] ?? null,
':ativo' => isset($data['ativo']) ? 1 : 0,
]);
$id = (int)$this->db->lastInsertId();
// inicializa horários semanais padrão
$stmt2 = $this->db->prepare("INSERT INTO profissionais_horarios (profissional_id, weekday, inicia, termina, folga) VALUES
(:pid,0,NULL,NULL,1),(:pid,1,'09:00','19:00',0),(:pid,2,'09:00','19:00',0),(:pid,3,'09:00','19:00',0),
(:pid,4,'09:00','19:00',0),(:pid,5,'09:00','19:00',0),(:pid,6,'09:00','17:00',0)");
$stmt2->execute([':pid' => $id]);
return $id;
}
public function update(int $id, int $barbeariaId, array $data): bool {
$stmt = $this->db->prepare("UPDATE profissionais
SET nome=:nome, email=:email, telefone=:telefone, especialidades=:especialidades, foto=:foto, ativo=:ativo
WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([
':nome' => $data['nome'],
':email' => $data['email'] ?? null,
':telefone' => $data['telefone'] ?? null,
':especialidades' => $data['especialidades'] ?? null,
':foto' => $data['foto'] ?? null,
':ativo' => isset($data['ativo']) ? 1 : 0,
':id' => $id,
':bid' => $barbeariaId,
]);
}
public function delete(int $id, int $barbeariaId): bool {
$stmt = $this->db->prepare("DELETE FROM profissionais WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class Cliente extends Model
{
public function all(int $barbeariaId): array {
$stmt = $this->db->prepare("SELECT * FROM clientes WHERE barbearia_id = :bid ORDER BY nome");
$stmt->execute([':bid' => $barbeariaId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function find(int $id, int $barbeariaId): ?array {
$stmt = $this->db->prepare("SELECT * FROM clientes WHERE id = :id AND barbearia_id = :bid");
$stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(int $barbeariaId, array $data): int {
$stmt = $this->db->prepare("INSERT INTO clientes (barbearia_id, user_id, nome, email, telefone, obs)
VALUES (:bid, :user_id, :nome, :email, :telefone, :obs)");
$stmt->execute([
':bid' => $barbeariaId,
':user_id' => $data['user_id'] ?? null,
':nome' => $data['nome'],
':email' => $data['email'] ?? null,
':telefone' => $data['telefone'] ?? null,
':obs' => $data['obs'] ?? null,
]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, int $barbeariaId, array $data): bool {
$stmt = $this->db->prepare("UPDATE clientes SET nome=:nome, email=:email, telefone=:telefone, obs=:obs
WHERE id=:id AND barbearia_id = :bid");
return $stmt->execute([
':nome' => $data['nome'],
':email' => $data['email'] ?? null,
':telefone' => $data['telefone'] ?? null,
':obs' => $data['obs'] ?? null,
':id' => $id,
':bid' => $barbeariaId,
]);
}
public function delete(int $id, int $barbeariaId): bool {
$stmt = $this->db->prepare("DELETE FROM clientes WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class HorarioFuncionamento extends Model
{
public function allByBarbearia(int $barbeariaId): array {
$stmt = $this->db->prepare("SELECT * FROM horarios_funcionamento WHERE barbearia_id = :bid ORDER BY weekday");
$stmt->execute([':bid' => $barbeariaId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function upsert(int $barbeariaId, int $weekday, ?string $abre, ?string $fecha, int $fechado): void {
$stmt = $this->db->prepare("INSERT INTO horarios_funcionamento (barbearia_id, weekday, abre, fecha, fechado)
VALUES (:bid,:w,:abre,:fecha,:fechado)
ON DUPLICATE KEY UPDATE abre=VALUES(abre), fecha=VALUES(fecha), fechado=VALUES(fechado)");
$stmt->execute([
':bid' => $barbeariaId,
':w' => $weekday,
':abre' => $abre,
':fecha' => $fecha,
':fechado' => $fechado,
]);
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
class Folga extends Model
{
public function add(int $barbeariaId, ?int $profissionalId, string $data, ?string $motivo): void {
$stmt = $this->db->prepare("INSERT INTO folgas (barbearia_id, profissional_id, data, motivo) VALUES (:bid, :pid, :data, :motivo)");
$stmt->execute([':bid' => $barbeariaId, ':pid' => $profissionalId, ':data' => $data, ':motivo' => $motivo]);
}
public function exists(int $barbeariaId, ?int $profissionalId, string $date): bool {
$stmt = $this->db->prepare("SELECT COUNT(*) FROM folgas WHERE barbearia_id=:bid AND (profissional_id <=> :pid) AND data = :data");
$stmt->execute([':bid' => $barbeariaId, ':pid' => $profissionalId, ':data' => $date]);
return (int)$stmt->fetchColumn() > 0;
}
}
<?php
namespace App\Models;
use App\Core\Model;
use PDO;
use DateTimeImmutable;
class Agendamento extends Model
{
public function all(int $barbeariaId, ?int $profissionalId = null, ?int $clienteId = null): array {
$sql = "SELECT a.*, s.nome as servico_nome, p.nome as profissional_nome, c.nome as cliente_nome
FROM agendamentos a
JOIN servicos s ON s.id = a.servico_id
JOIN profissionais p ON p.id = a.profissional_id
JOIN clientes c ON c.id = a.cliente_id
WHERE a.barbearia_id = :bid";
$params = [':bid' => $barbeariaId];
if ($profissionalId) { $sql .= " AND a.profissional_id = :pid"; $params[':pid'] = $profissionalId; }
if ($clienteId) { $sql .= " AND a.cliente_id = :cid"; $params[':cid'] = $clienteId; }
$sql .= " ORDER BY a.inicio DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function find(int $id, int $barbeariaId): ?array {
$stmt = $this->db->prepare("SELECT * FROM agendamentos WHERE id = :id AND barbearia_id = :bid");
$stmt->execute([':id' => $id, ':bid' => $barbeariaId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(array $data): int {
$stmt = $this->db->prepare("INSERT INTO agendamentos (barbearia_id, servico_id, profissional_id, cliente_id, inicio, fim, preco, status, notas, created_by)
VALUES (:bid,:sid,:pid,:cid,:inicio,:fim,:preco,:status,:notas,:created_by)");
$stmt->execute([
':bid' => $data['barbearia_id'],
':sid' => $data['servico_id'],
':pid' => $data['profissional_id'],
':cid' => $data['cliente_id'],
':inicio' => $data['inicio'],
':fim' => $data['fim'],
':preco' => $data['preco'],
':status' => $data['status'] ?? 'pendente',
':notas' => $data['notas'] ?? null,
':created_by' => $data['created_by'] ?? null,
]);
return (int)$this->db->lastInsertId();
}
public function updateStatus(int $id, int $barbeariaId, string $status): bool {
$stmt = $this->db->prepare("UPDATE agendamentos SET status = :status WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([':status' => $status, ':id' => $id, ':bid' => $barbeariaId]);
}
public function reschedule(int $id, int $barbeariaId, string $inicio, string $fim): bool {
$stmt = $this->db->prepare("UPDATE agendamentos SET inicio = :inicio, fim = :fim, status = 'remarcado'
WHERE id = :id AND barbearia_id = :bid");
return $stmt->execute([':inicio' => $inicio, ':fim' => $fim, ':id' => $id, ':bid' => $barbeariaId]);
}
public function isSlotAvailable(int $barbeariaId, int $profissionalId, string $inicio, string $fim): bool {
// 1) Folgas
$date = (new DateTimeImmutable($inicio))->format('Y-m-d');
$stmt = $this->db->prepare("SELECT COUNT(*) FROM folgas WHERE barbearia_id=:bid AND (profissional_id IS NULL OR profissional_id=:pid) AND data=:d");
$stmt->execute([':bid' => $barbeariaId, ':pid' => $profissionalId, ':d' => $date]);
if ((int)$stmt->fetchColumn() > 0) return false;
// 2) Horário de funcionamento da barbearia
$weekday = (int)(new DateTimeImmutable($inicio))->format('w'); // 0..6
$stmt = $this->db->prepare("SELECT * FROM horarios_funcionamento WHERE barbearia_id=:bid AND weekday=:w");
$stmt->execute([':bid' => $barbeariaId, ':w' => $weekday]);
$hf = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$hf || (int)$hf['fechado'] === 1) return false;
$open = $date.' '.$hf['abre'].':00';
$close = $date.' '.$hf['fecha'].':00';
if ($inicio < $open || $fim > $close) return false;
// 3) Horário do profissional
$stmt = $this->db->prepare("SELECT * FROM profissionais_horarios WHERE profissional_id=:pid AND weekday=:w");
$stmt->execute([':pid' => $profissionalId, ':w' => $weekday]);
$hp = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$hp || (int)$hp['folga'] === 1) return false;
$popen = $date.' '.$hp['inicia'].':00';
$pclose = $date.' '.$hp['termina'].':00';
if ($inicio < $popen || $fim > $pclose) return false;
// 4) Conflitos com agendamentos existentes
$stmt = $this->db->prepare("SELECT COUNT(*) FROM agendamentos
WHERE barbearia_id = :bid AND profissional_id = :pid
AND status NOT IN ('cancelado')
AND ((inicio < :fim) AND (fim > :inicio))");
$stmt->execute([
':bid' => $barbeariaId,
':pid' => $profissionalId,
':inicio' => $inicio,
':fim' => $fim,
]);
return ((int)$stmt->fetchColumn() === 0);
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
use App\Models\User;
use App\Models\Barbearia;
use App\Services\SubscriptionService;
use function App\Core\set_flash;
use function App\Core\flash;
use function App\Core\e;
class AuthController extends Controller
{
public function login(): void {
if (\App\Core\Auth::check()) { $this->redirect('/dashboard'); }
$token = Auth::csrfToken();
$this->render('auth/login', ['csrf' => $token], 'auth');
}
public function loginPost(): void {
$this->csrf();
$email = trim($_POST['email'] ?? '');
$senha = $_POST['senha'] ?? '';
$userModel = new User();
$user = $userModel->findByEmail($email);
if (!$user || !password_verify($senha, $user['senha'])) {
set_flash('error', 'Credenciais inválidas.');
$this->redirect('/login');
}
(new User())->updateLastLogin((int)$user['id']);
Auth::login($user);
$this->redirect('/dashboard');
}
public function logout(): void {
Auth::logout();
$this->redirect('/login');
}
public function register(): void {
$token = Auth::csrfToken();
$this->render('auth/register', ['csrf' => $token], 'auth');
}
public function registerPost(): void {
$this->csrf();
$nomeBarb = trim($_POST['barbearia_nome'] ?? '');
$emailBarb = trim($_POST['barbearia_email'] ?? '');
$adminNome = trim($_POST['admin_nome'] ?? '');
$adminEmail = trim($_POST['admin_email'] ?? '');
$adminSenha = $_POST['admin_senha'] ?? '';
if (!$nomeBarb || !$adminNome || !$adminEmail || !$adminSenha) {
set_flash('error', 'Preencha todos os campos obrigatórios.');
$this->redirect('/register');
}
$barbModel = new Barbearia();
$barbeariaId = $barbModel->create([
'nome' => $nomeBarb,
'email' => $emailBarb,
]);
$userModel = new User();
$adminId = $userModel->create([
'barbearia_id' => $barbeariaId,
'nome' => $adminNome,
'email' => $adminEmail,
'senha' => $adminSenha,
'role' => 'admin',
]);
// cria assinatura trial por 30 dias no plano Starter
$db = Database::conn();
$db->prepare("INSERT INTO assinaturas (barbearia_id, plano_id, status, inicio, validade)
VALUES (:bid, (SELECT id FROM planos WHERE nome='Starter' LIMIT 1), 'ativa', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY))")
->execute([':bid' => $barbeariaId]);
set_flash('success', 'Cadastro realizado! Faça login.');
$this->redirect('/login');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
class DashboardController extends Controller
{
public function index(): void {
$this->requireAuth();
$user = Auth::user();
$db = Database::conn();
$stats = [
'agendamentos_hoje' => 0,
'faturamento_mes' => 0.0,
'clientes_total' => 0,
];
if ($user['barbearia_id']) {
$stmt = $db->prepare("SELECT COUNT(*) FROM agendamentos WHERE barbearia_id=:bid AND DATE(inicio) = CURDATE()");
$stmt->execute([':bid' => $user['barbearia_id']]);
$stats['agendamentos_hoje'] = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COALESCE(SUM(preco),0) FROM agendamentos
WHERE barbearia_id=:bid AND status IN ('confirmado','concluido')
AND MONTH(inicio)=MONTH(CURDATE()) AND YEAR(inicio)=YEAR(CURDATE())");
$stmt->execute([':bid' => $user['barbearia_id']]);
$stats['faturamento_mes'] = (float)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COUNT(*) FROM clientes WHERE barbearia_id=:bid");
$stmt->execute([':bid' => $user['barbearia_id']]);
$stats['clientes_total'] = (int)$stmt->fetchColumn();
}
$this->render('dashboard/index', ['user' => $user, 'stats' => $stats]);
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Models\Servico;
use function App\Core\set_flash;
class ServicosController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$servicoModel = new Servico();
$list = $servicoModel->all(Auth::user()['barbearia_id']);
$this->render('servicos/index', ['servicos' => $list]);
}
public function create(): void {
$this->requireAuth(['admin']);
$csrf = Auth::csrfToken();
$this->render('servicos/form', ['csrf' => $csrf, 'servico' => null]);
}
public function store(): void {
$this->requireAuth(['admin']);
$this->csrf();
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'duracao_min' => (int)($_POST['duracao_min'] ?? 0),
'preco' => (float)($_POST['preco'] ?? 0),
'ativo' => isset($_POST['ativo']) ? 1 : 0,
];
if (!$data['nome'] || $data['duracao_min'] <= 0) {
set_flash('error', 'Informe nome e duração.');
$this->redirect('/servicos/novo');
}
(new Servico())->create(Auth::user()['barbearia_id'], $data);
set_flash('success', 'Serviço criado.');
$this->redirect('/servicos');
}
public function edit($id): void {
$this->requireAuth(['admin']);
$servico = (new Servico())->find((int)$id, Auth::user()['barbearia_id']);
if (!$servico) { http_response_code(404); exit('Não encontrado'); }
$csrf = Auth::csrfToken();
$this->render('servicos/form', ['csrf' => $csrf, 'servico' => $servico]);
}
public function update($id): void {
$this->requireAuth(['admin']);
$this->csrf();
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'duracao_min' => (int)($_POST['duracao_min'] ?? 0),
'preco' => (float)($_POST['preco'] ?? 0),
'ativo' => isset($_POST['ativo']) ? 1 : 0,
];
(new Servico())->update((int)$id, Auth::user()['barbearia_id'], $data);
set_flash('success', 'Serviço atualizado.');
$this->redirect('/servicos');
}
public function destroy($id): void {
$this->requireAuth(['admin']);
$this->csrf();
(new Servico())->delete((int)$id, Auth::user()['barbearia_id']);
set_flash('success', 'Serviço removido.');
$this->redirect('/servicos');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Models\Profissional;
use App\Models\User;
use App\Services\SubscriptionService;
use function App\Core\set_flash;
use function App\Core\ensure_dir;
class ProfissionaisController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$list = (new Profissional())->all(Auth::user()['barbearia_id']);
$this->render('profissionais/index', ['profissionais' => $list]);
}
public function create(): void {
$this->requireAuth(['admin']);
$csrf = Auth::csrfToken();
$this->render('profissionais/form', ['csrf' => $csrf, 'profissional' => null]);
}
public function store(): void {
$this->requireAuth(['admin']);
$this->csrf();
$barbeariaId = Auth::user()['barbearia_id'];
if (!SubscriptionService::canAddProfessional($barbeariaId)) {
set_flash('error', 'Limite de profissionais do seu plano foi atingido.');
$this->redirect('/profissionais');
}
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'telefone' => trim($_POST['telefone'] ?? ''),
'especialidades' => trim($_POST['especialidades'] ?? ''),
'ativo' => isset($_POST['ativo']) ? 1 : 0,
];
// Optional: create a linked user account for the professional
if (!empty($_POST['criar_conta']) && !empty($_POST['senha'])) {
$userId = (new User())->create([
'barbearia_id' => $barbeariaId,
'nome' => $data['nome'],
'email' => $data['email'],
'senha' => $_POST['senha'],
'role' => 'profissional',
]);
$data['user_id'] = $userId;
}
// Upload de foto
$fotoPath = null;
if (!empty($_FILES['foto']['name'])) {
$file = $_FILES['foto'];
if ($file['error'] === UPLOAD_ERR_OK) {
if ($file['size'] > (MAX_UPLOAD_MB * 1024 * 1024)) {
set_flash('error', 'Arquivo muito grande.');
$this->redirect('/profissionais/novo');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
if (!in_array($mime, ['image/jpeg','image/png','image/webp'])) {
set_flash('error', 'Formato de imagem inválido.');
$this->redirect('/profissionais/novo');
}
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg'
};
$dir = UPLOAD_DIR . '/profissionais';
ensure_dir($dir);
$name = uniqid('prof_', true) . '.' . $ext;
$dest = $dir . '/' . $name;
move_uploaded_file($file['tmp_name'], $dest);
$fotoPath = '/uploads/profissionais/' . $name;
}
}
$data['foto'] = $fotoPath;
(new Profissional())->create($barbeariaId, $data);
set_flash('success', 'Profissional criado.');
$this->redirect('/profissionais');
}
public function edit($id): void {
$this->requireAuth(['admin']);
$profissional = (new Profissional())->find((int)$id, Auth::user()['barbearia_id']);
if (!$profissional) { http_response_code(404); exit('Não encontrado'); }
$csrf = Auth::csrfToken();
$this->render('profissionais/form', ['csrf' => $csrf, 'profissional' => $profissional]);
}
public function update($id): void {
$this->requireAuth(['admin']);
$this->csrf();
$barbeariaId = Auth::user()['barbearia_id'];
$prof = (new Profissional())->find((int)$id, $barbeariaId);
if (!$prof) { http_response_code(404); exit('Não encontrado'); }
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'telefone' => trim($_POST['telefone'] ?? ''),
'especialidades' => trim($_POST['especialidades'] ?? ''),
'foto' => $prof['foto'],
'ativo' => isset($_POST['ativo']) ? 1 : 0,
];
if (!empty($_FILES['foto']['name'])) {
$file = $_FILES['foto'];
if ($file['error'] === UPLOAD_ERR_OK) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
if (in_array($mime, ['image/jpeg','image/png','image/webp'])) {
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
};
$dir = UPLOAD_DIR . '/profissionais';
ensure_dir($dir);
$name = uniqid('prof_', true) . '.' . $ext;
$dest = $dir . '/' . $name;
move_uploaded_file($file['tmp_name'], $dest);
$data['foto'] = '/uploads/profissionais/' . $name;
}
}
}
(new Profissional())->update((int)$id, $barbeariaId, $data);
set_flash('success', 'Profissional atualizado.');
$this->redirect('/profissionais');
}
public function destroy($id): void {
$this->requireAuth(['admin']);
$this->csrf();
(new Profissional())->delete((int)$id, Auth::user()['barbearia_id']);
set_flash('success', 'Profissional removido.');
$this->redirect('/profissionais');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Models\Cliente;
use App\Models\User;
use function App\Core\set_flash;
class ClientesController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$list = (new Cliente())->all(Auth::user()['barbearia_id']);
$this->render('clientes/index', ['clientes' => $list]);
}
public function create(): void {
$this->requireAuth(['admin']);
$csrf = Auth::csrfToken();
$this->render('clientes/form', ['csrf' => $csrf, 'cliente' => null]);
}
public function store(): void {
$this->requireAuth(['admin']);
$this->csrf();
$barbeariaId = Auth::user()['barbearia_id'];
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'telefone' => trim($_POST['telefone'] ?? ''),
'obs' => trim($_POST['obs'] ?? ''),
];
if (!empty($_POST['criar_conta']) && !empty($_POST['senha'])) {
$userId = (new User())->create([
'barbearia_id' => $barbeariaId,
'nome' => $data['nome'],
'email' => $data['email'],
'senha' => $_POST['senha'],
'role' => 'cliente',
]);
$data['user_id'] = $userId;
}
(new Cliente())->create($barbeariaId, $data);
set_flash('success', 'Cliente criado.');
$this->redirect('/clientes');
}
public function edit($id): void {
$this->requireAuth(['admin']);
$cliente = (new Cliente())->find((int)$id, Auth::user()['barbearia_id']);
if (!$cliente) { http_response_code(404); exit('Não encontrado'); }
$csrf = Auth::csrfToken();
$this->render('clientes/form', ['csrf' => $csrf, 'cliente' => $cliente]);
}
public function update($id): void {
$this->requireAuth(['admin']);
$this->csrf();
$data = [
'nome' => trim($_POST['nome'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'telefone' => trim($_POST['telefone'] ?? ''),
'obs' => trim($_POST['obs'] ?? ''),
];
(new Cliente())->update((int)$id, Auth::user()['barbearia_id'], $data);
set_flash('success', 'Cliente atualizado.');
$this->redirect('/clientes');
}
public function destroy($id): void {
$this->requireAuth(['admin']);
$this->csrf();
(new Cliente())->delete((int)$id, Auth::user()['barbearia_id']);
set_flash('success', 'Cliente removido.');
$this->redirect('/clientes');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
use App\Models\Agendamento;
use App\Models\Servico;
use App\Models\Profissional;
use App\Models\Cliente;
use App\Services\NotificationService;
use App\Services\SubscriptionService;
use DateTimeImmutable;
use function App\Core\set_flash;
class AgendamentosController extends Controller
{
public function index(): void {
$this->requireAuth(['admin','profissional','cliente']);
$user = Auth::user();
$ag = new Agendamento();
$list = $ag->all($user['barbearia_id'],
$user['role']==='profissional' ? $this->getProfIdByUser($user['id']) : null,
$user['role']==='cliente' ? $this->getClienteIdByUser($user['id']) : null
);
$this->render('agendamentos/index', ['agendamentos' => $list, 'role' => $user['role']]);
}
public function create(): void {
$this->requireAuth(['admin','cliente']);
$user = Auth::user();
$csrf = Auth::csrfToken();
$servicos = (new Servico())->all($user['barbearia_id']);
$profissionais = (new Profissional())->all($user['barbearia_id']);
$clientes = (new Cliente())->all($user['barbearia_id']);
$this->render('agendamentos/form', compact('csrf','servicos','profissionais','clientes'));
}
public function store(): void {
$this->requireAuth(['admin','cliente']);
$this->csrf();
$user = Auth::user();
if (!SubscriptionService::canCreateAppointment($user['barbearia_id'])) {
set_flash('error', 'Limite de agendamentos do seu plano foi atingido este mês.');
$this->redirect('/agendamentos');
}
$servicoId = (int)($_POST['servico_id'] ?? 0);
$profissionalId = (int)($_POST['profissional_id'] ?? 0);
$clienteId = (int)($_POST['cliente_id'] ?? 0);
$data = trim($_POST['data'] ?? '');
$hora = trim($_POST['hora'] ?? '');
$notas = trim($_POST['notas'] ?? '');
$db = Database::conn();
// obter duração/preço do serviço
$stmt = $db->prepare("SELECT duracao_min, preco FROM servicos WHERE id=:sid AND barbearia_id=:bid");
$stmt->execute([':sid' => $servicoId, ':bid' => $user['barbearia_id']]);
$serv = $stmt->fetch();
if (!$serv) { set_flash('error','Serviço inválido.'); $this->redirect('/agendamentos/novo'); }
$inicio = new DateTimeImmutable($data.' '.$hora.':00');
$fim = $inicio->modify('+' . (int)$serv['duracao_min'] . ' minutes');
$ag = new Agendamento();
if (!$ag->isSlotAvailable($user['barbearia_id'], $profissionalId, $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'))) {
set_flash('error', 'Horário indisponível ou fora do expediente.');
$this->redirect('/agendamentos/novo');
}
$id = $ag->create([
'barbearia_id' => $user['barbearia_id'],
'servico_id' => $servicoId,
'profissional_id' => $profissionalId,
'cliente_id' => $clienteId,
'inicio' => $inicio->format('Y-m-d H:i:s'),
'fim' => $fim->format('Y-m-d H:i:s'),
'preco' => (float)$serv['preco'],
'status' => 'pendente',
'notas' => $notas,
'created_by' => $user['id'],
]);
// Notificações (stubs)
NotificationService::sendEmail('cliente@example.com', 'Novo agendamento', '<p>Seu agendamento foi criado.</p>');
NotificationService::sendWhatsApp('5599999999999', 'Agendamento criado.');
set_flash('success', 'Agendamento criado.');
$this->redirect('/agendamentos');
}
public function confirm($id): void {
$this->requireAuth(['admin','profissional']);
$this->csrf();
$user = Auth::user();
(new Agendamento())->updateStatus((int)$id, $user['barbearia_id'], 'confirmado');
set_flash('success', 'Agendamento confirmado.');
$this->redirect('/agendamentos');
}
public function cancel($id): void {
$this->requireAuth(['admin','profissional','cliente']);
$this->csrf();
$user = Auth::user();
$ag = (new Agendamento())->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
// Política de cancelamento
$diffHours = (strtotime($ag['inicio']) - time()) / 3600;
if ($diffHours < CANCELLATION_POLICY_HOURS && $user['role'] === 'cliente') {
set_flash('error', 'Cancelamento não permitido dentro da política.');
$this->redirect('/agendamentos');
}
(new Agendamento())->updateStatus((int)$id, $user['barbearia_id'], 'cancelado');
set_flash('success', 'Agendamento cancelado.');
$this->redirect('/agendamentos');
}
public function rescheduleForm($id): void {
$this->requireAuth(['admin','cliente']);
$user = Auth::user();
$ag = (new Agendamento())->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
$csrf = Auth::csrfToken();
$this->render('agendamentos/form-remarcar', ['csrf'=>$csrf,'agendamento'=>$ag]);
}
public function reschedule($id): void {
$this->requireAuth(['admin','cliente']);
$this->csrf();
$user = Auth::user();
$agModel = new Agendamento();
$ag = $agModel->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
$data = trim($_POST['data'] ?? '');
$hora = trim($_POST['hora'] ?? '');
$inicio = new \DateTimeImmutable($data.' '.$hora.':00');
$dur = (strtotime($ag['fim']) - strtotime($ag['inicio'])) / 60;
$fim = $inicio->modify('+' . (int)$dur . ' minutes');
$diffHours = (strtotime($ag['inicio']) - time()) / 3600;
if ($diffHours < RESCHEDULE_POLICY_HOURS && $user['role'] === 'cliente') {
set_flash('error', 'Reagendamento não permitido dentro da política.');
$this->redirect('/agendamentos');
}
if (!$agModel->isSlotAvailable($user['barbearia_id'], (int)$ag['profissional_id'], $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'))) {
set_flash('error', 'Novo horário indisponível.');
$this->redirect('/agendamentos/remarcar/'.$id);
}
$agModel->reschedule((int)$id, $user['barbearia_id'], $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'));
set_flash('success', 'Agendamento remarcado.');
$this->redirect('/agendamentos');
}
private function getProfIdByUser(int $userId): ?int {
$db = \App\Core\Database::conn();
$stmt = $db->prepare("SELECT id FROM profissionais WHERE user_id = :uid");
$stmt->execute([':uid' => $userId]);
$id = $stmt->fetchColumn();
return $id ? (int)$id : null;
}
private function getClienteIdByUser(int $userId): ?int {
$db = \App\Core\Database::conn();
$stmt = $db->prepare("SELECT id FROM clientes WHERE user_id = :uid");
$stmt->execute([':uid' => $userId]);
$id = $stmt->fetchColumn();
return $id ? (int)$id : null;
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
class RelatoriosController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$db = \App\Core\Database::conn();
$bid = Auth::user()['barbearia_id'];
$from = $_GET['de'] ?? date('Y-m-01');
$to = $_GET['ate'] ?? date('Y-m-d');
$stmt = $db->prepare("SELECT COUNT(*) FROM agendamentos
WHERE barbearia_id=:bid AND inicio BETWEEN :de AND :ate 23:59:59");
$stmt->execute([':bid' => $bid, ':de' => $from, ':ate' => $to]);
$totalAtend = (int)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT COALESCE(SUM(preco),0) FROM agendamentos
WHERE barbearia_id=:bid AND status IN ('confirmado','concluido')
AND inicio BETWEEN :de AND :ate 23:59:59");
$stmt->execute([':bid' => $bid, ':de' => $from, ':ate' => $to]);
$fat = (float)$stmt->fetchColumn();
$stmt = $db->prepare("SELECT s.nome, COUNT(*) as qtd, SUM(a.preco) as total
FROM agendamentos a JOIN servicos s ON s.id=a.servico_id
WHERE a.barbearia_id=:bid AND a.inicio BETWEEN :de AND :ate 23:59:59
GROUP BY s.id ORDER BY qtd DESC LIMIT 5");
$stmt->execute([':bid' => $bid, ':de' => $from, ':ate' => $to]);
$topServ = $stmt->fetchAll();
$this->render('relatorios/index', [
'from' => $from,
'to' => $to,
'totalAtend' => $totalAtend,
'faturamento' => $fat,
'topServicos' => $topServ
]);
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
use App\Services\SubscriptionService;
use function App\Core\set_flash;
class AssinaturasController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$db = Database::conn();
$bid = Auth::user()['barbearia_id'];
$sub = SubscriptionService::getActiveSubscription($bid);
$planos = $db->query("SELECT * FROM planos WHERE ativo=1 ORDER BY preco ASC")->fetchAll();
$csrf = \App\Core\Auth::csrfToken();
$this->render('assinaturas/index', compact('sub','planos','csrf'));
}
public function activate(): void {
$this->requireAuth(['admin']);
$this->csrf();
$db = Database::conn();
$bid = Auth::user()['barbearia_id'];
$planoId = (int)($_POST['plano_id'] ?? 0);
$db->prepare("INSERT INTO assinaturas (barbearia_id, plano_id, status, inicio, validade)
VALUES (:bid,:pid,'ativa', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY))")
->execute([':bid'=>$bid, ':pid'=>$planoId]);
set_flash('success', 'Plano ativado/renovado por 30 dias.');
$this->redirect('/assinaturas');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Models\HorarioFuncionamento;
use App\Models\Folga;
use App\Models\Profissional;
use function App\Core\set_flash;
class BarbeariaController extends Controller
{
public function index(): void {
$this->requireAuth(['admin']);
$bid = Auth::user()['barbearia_id'];
$hf = (new HorarioFuncionamento())->allByBarbearia($bid);
$profissionais = (new Profissional())->all($bid);
$csrf = Auth::csrfToken();
$this->render('barbearia/config', compact('hf','profissionais','csrf'));
}
public function saveHorarios(): void {
$this->requireAuth(['admin']);
$this->csrf();
$bid = Auth::user()['barbearia_id'];
$hfModel = new HorarioFuncionamento();
for ($w=0;$w<=6;$w++) {
$fechado = isset($_POST["fechado_$w"]) ? 1 : 0;
$abre = $_POST["abre_$w"] ?? null;
$fecha = $_POST["fecha_$w"] ?? null;
if ($fechado) { $abre = $fecha = null; }
$hfModel->upsert($bid, $w, $abre, $fecha, $fechado);
}
set_flash('success', 'Horários atualizados.');
$this->redirect('/configuracoes');
}
public function saveFolgas(): void {
$this->requireAuth(['admin']);
$this->csrf();
$bid = Auth::user()['barbearia_id'];
$pid = !empty($_POST['profissional_id']) ? (int)$_POST['profissional_id'] : null;
$data = $_POST['data'] ?? null;
$motivo = $_POST['motivo'] ?? null;
if ($data) {
(new Folga())->add($bid, $pid, $data, $motivo);
set_flash('success', 'Folga registrada.');
}
$this->redirect('/configuracoes');
}
}
<?php use function App\Core\flash; use function App\Core\e; ?>
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="utf-8">
<title><?= e(APP_NAME) ?> - Autenticação</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<main class="container py-5">
<div class="row justify-content-center">
<div class="col-md-5">
<?php if ($msg = flash('error')): ?>
<div class="alert alert-danger"><?= e($msg) ?></div>
<?php endif; ?>
<?php if ($msg = flash('success')): ?>
<div class="alert alert-success"><?= e($msg) ?></div>
<?php endif; ?>
<?= $content ?? '' ?>
</div>
</div>
</main>
</body>
</html>
<?php use function App\Core\flash; use function App\Core\e; use App\Core\Auth; $user = Auth::user(); ?>
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="utf-8">
<title><?= e(APP_NAME) ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/styles.css" rel="stylesheet">
</head>
<body>
<?php include __DIR__ . '/../components/navbar.php'; ?>
<main class="container py-4">
<?php if ($msg = flash('error')): ?>
<div class="alert alert-danger"><?= e($msg) ?></div>
<?php endif; ?>
<?php if ($msg = flash('success')): ?>
<div class="alert alert-success"><?= e($msg) ?></div>
<?php endif; ?>
<?= $content ?? '' ?>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>
<?php use App\Core\Auth; use function App\Core\e; $user = Auth::user(); ?>
<nav class="navbar navbar-expand-lg bg-dark navbar-dark">
<div class="container">
<a class="navbar-brand" href="/dashboard"><?= e(APP_NAME) ?></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div id="nav" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<?php if ($user): ?>
<li class="nav-item"><a href="/agendamentos" class="nav-link">Agendamentos</a></li>
<?php if ($user['role']==='admin'): ?>
<li class="nav-item"><a href="/servicos" class="nav-link">Serviços</a></li>
<li class="nav-item"><a href="/profissionais" class="nav-link">Profissionais</a></li>
<li class="nav-item"><a href="/clientes" class="nav-link">Clientes</a></li>
<li class="nav-item"><a href="/relatorios" class="nav-link">Relatórios</a></li>
<li class="nav-item"><a href="/assinaturas" class="nav-link">Assinaturas</a></li>
<li class="nav-item"><a href="/configuracoes" class="nav-link">Configurações</a></li>
<?php endif; ?>
<?php endif; ?>
</ul>
<ul class="navbar-nav">
<?php if ($user): ?>
<li class="nav-item">
<span class="navbar-text me-3"><?= e($user['nome']) ?> (<?= e($user['role']) ?>)</span>
</li>
<li class="nav-item"><a href="/logout" class="btn btn-outline-light btn-sm">Sair</a></li>
<?php else: ?>
<li class="nav-item"><a href="/login" class="nav-link">Entrar</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
<?php use function App\Core\e; ?>
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Entrar</h1>
<form method="post" action="/login" novalidate>
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="mb-3">
<label class="form-label">E-mail</label>
<input type="email" name="email" class="form-control" required placeholder="voce@exemplo.com">
</div>
<div class="mb-3">
<label class="form-label">Senha</label>
<input type="password" name="senha" class="form-control" required>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary">Entrar</button>
<a href="/register" class="btn btn-link">Registrar barbearia</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; ?>
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Registrar Barbearia</h1>
<form method="post" action="/register">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<h6 class="text-muted">Dados da Barbearia</h6>
<div class="mb-2">
<label class="form-label">Nome</label>
<input type="text" name="barbearia_nome" class="form-control" required>
</div>
<div class="mb-2">
<label class="form-label">E-mail</label>
<input type="email" name="barbearia_email" class="form-control">
</div>
<hr>
<h6 class="text-muted">Administrador</h6>
<div class="mb-2">
<label class="form-label">Nome</label>
<input type="text" name="admin_nome" class="form-control" required>
</div>
<div class="mb-2">
<label class="form-label">E-mail</label>
<input type="email" name="admin_email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Senha</label>
<input type="password" name="admin_senha" class="form-control" required>
</div>
<button class="btn btn-primary w-100">Criar</button>
</form>
</div>
</div>
<?php use function App\Core\e; use function App\Core\format_money; ?>
<h1 class="h4 mb-4">Olá, <?= e($user['nome']) ?>!</h1>
<div class="row g-3">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small">Agendamentos Hoje</div>
<div class="display-6"><?= e((string)$stats['agendamentos_hoje']) ?></div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small">Faturamento do Mês</div>
<div class="display-6"><?= e(format_money((float)$stats['faturamento_mes'])) ?></div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="text-muted small">Total de Clientes</div>
<div class="display-6"><?= e((string)$stats['clientes_total']) ?></div>
</div>
</div>
</div>
</div>
<?php use function App\Core\e; use function App\Core\format_money; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4">Serviços</h1>
<a class="btn btn-primary" href="/servicos/novo">Novo</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped align-middle mb-0">
<thead><tr><th>Nome</th><th>Duração</th><th>Preço</th><th>Ativo</th><th></th></tr></thead>
<tbody>
<?php foreach ($servicos as $s): ?>
<tr>
<td><?= e($s['nome']) ?></td>
<td><?= e((string)$s['duracao_min']) ?> min</td>
<td><?= e(format_money((float)$s['preco'])) ?></td>
<td><?= $s['ativo'] ? 'Sim' : 'Não' ?></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="/servicos/editar/<?= e((string)$s['id']) ?>">Editar</a>
<form method="post" action="/servicos/delete/<?= e((string)$s['id']) ?>" class="d-inline">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e(\App\Core\Auth::csrfToken()) ?>">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Excluir?')">Excluir</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php use function App\Core\e; $editing = !!$servico; ?>
<div class="card">
<div class="card-body">
<h1 class="h5 mb-3"><?= $editing ? 'Editar Serviço' : 'Novo Serviço' ?></h1>
<form method="post" action="<?= $editing ? '/servicos/editar/'.e((string)$servico['id']) : '/servicos/novo' ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nome</label>
<input type="text" name="nome" class="form-control" required value="<?= e($servico['nome'] ?? '') ?>">
</div>
<div class="col-md-3">
<label class="form-label">Duração (min)</label>
<input type="number" name="duracao_min" class="form-control" required min="5" step="5" value="<?= e((string)($servico['duracao_min'] ?? 30)) ?>">
</div>
<div class="col-md-3">
<label class="form-label">Preço</label>
<input type="number" name="preco" class="form-control" required min="0" step="0.01" value="<?= e((string)($servico['preco'] ?? '0.00')) ?>">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="ativo" id="ativo" <?= !isset($servico) || ($servico['ativo'] ?? 0) ? 'checked' : '' ?>>
<label class="form-check-label" for="ativo">Ativo</label>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary"><?= $editing ? 'Salvar' : 'Criar' ?></button>
<a href="/servicos" class="btn btn-secondary">Voltar</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4">Profissionais</h1>
<a class="btn btn-primary" href="/profissionais/novo">Novo</a>
</div>
<div class="row g-3">
<?php foreach ($profissionais as $p): ?>
<div class="col-md-4">
<div class="card h-100">
<?php if ($p['foto']): ?>
<img src="<?= e($p['foto']) ?>" class="card-img-top" alt="Foto de <?= e($p['nome']) ?>">
<?php endif; ?>
<div class="card-body">
<h5 class="card-title"><?= e($p['nome']) ?></h5>
<div class="text-muted small mb-2"><?= e($p['especialidades']) ?></div>
<div class="small">Contato: <?= e($p['telefone']) ?> <?= e($p['email']) ?></div>
<div class="badge bg-<?= $p['ativo'] ? 'success':'secondary' ?> mt-2"><?= $p['ativo'] ? 'Ativo' : 'Inativo' ?></div>
</div>
<div class="card-footer d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="/profissionais/editar/<?= e((string)$p['id']) ?>">Editar</a>
<form method="post" action="/profissionais/delete/<?= e((string)$p['id']) ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e(\App\Core\Auth::csrfToken()) ?>">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Excluir?')">Excluir</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php use function App\Core\e; $editing = !!$profissional; ?>
<div class="card">
<div class="card-body">
<h1 class="h5 mb-3"><?= $editing ? 'Editar Profissional' : 'Novo Profissional' ?></h1>
<form method="post" enctype="multipart/form-data" action="<?= $editing ? '/profissionais/editar/'.e((string)$profissional['id']) : '/profissionais/novo' ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nome</label>
<input type="text" name="nome" class="form-control" required value="<?= e($profissional['nome'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">E-mail</label>
<input type="email" name="email" class="form-control" value="<?= e($profissional['email'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Telefone</label>
<input type="text" name="telefone" class="form-control" value="<?= e($profissional['telefone'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Especialidades</label>
<input type="text" name="especialidades" class="form-control" value="<?= e($profissional['especialidades'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Foto</label>
<input type="file" name="foto" class="form-control">
<?php if (!empty($profissional['foto'])): ?>
<img src="<?= e($profissional['foto']) ?>" class="img-thumbnail mt-2" style="max-width:150px;">
<?php endif; ?>
</div>
<?php if (!$editing): ?>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="criar_conta" id="criar_conta">
<label class="form-check-label" for="criar_conta">Criar conta de acesso para o profissional</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Senha (se criar conta)</label>
<input type="password" name="senha" class="form-control">
</div>
<?php endif; ?>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="ativo" id="ativo" <?= !isset($profissional) || ($profissional['ativo'] ?? 0) ? 'checked' : '' ?>>
<label class="form-check-label" for="ativo">Ativo</label>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary"><?= $editing ? 'Salvar' : 'Criar' ?></button>
<a href="/profissionais" class="btn btn-secondary">Voltar</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4">Clientes</h1>
<a class="btn btn-primary" href="/clientes/novo">Novo</a>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead><tr><th>Nome</th><th>E-mail</th><th>Telefone</th><th></th></tr></thead>
<tbody>
<?php foreach ($clientes as $c): ?>
<tr>
<td><?= e($c['nome']) ?></td>
<td><?= e($c['email']) ?></td>
<td><?= e($c['telefone']) ?></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="/clientes/editar/<?= e((string)$c['id']) ?>">Editar</a>
<form class="d-inline" method="post" action="/clientes/delete/<?= e((string)$c['id']) ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e(\App\Core\Auth::csrfToken()) ?>">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Excluir?')">Excluir</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php use function App\Core\e; $editing = !!$cliente; ?>
<div class="card">
<div class="card-body">
<h1 class="h5 mb-3"><?= $editing ? 'Editar Cliente' : 'Novo Cliente' ?></h1>
<form method="post" action="<?= $editing ? '/clientes/editar/'.e((string)$cliente['id']) : '/clientes/novo' ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nome</label>
<input type="text" name="nome" class="form-control" required value="<?= e($cliente['nome'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">E-mail</label>
<input type="email" name="email" class="form-control" value="<?= e($cliente['email'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Telefone</label>
<input type="text" name="telefone" class="form-control" value="<?= e($cliente['telefone'] ?? '') ?>">
</div>
<div class="col-12">
<label class="form-label">Observações</label>
<textarea name="obs" class="form-control" rows="3"><?= e($cliente['obs'] ?? '') ?></textarea>
</div>
<?php if (!$editing): ?>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="criar_conta" id="criar_conta">
<label class="form-check-label" for="criar_conta">Criar conta de acesso para o cliente</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Senha (se criar conta)</label>
<input type="password" name="senha" class="form-control">
</div>
<?php endif; ?>
</div>
<div class="mt-3">
<button class="btn btn-primary"><?= $editing ? 'Salvar' : 'Criar' ?></button>
<a href="/clientes" class="btn btn-secondary">Voltar</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; use function App\Core\format_money; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4">Agendamentos</h1>
<?php if (in_array($role, ['admin','cliente'])): ?>
<a class="btn btn-primary" href="/agendamentos/novo">Novo</a>
<?php endif; ?>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped align-middle mb-0">
<thead><tr>
<th>Data/Hora</th><th>Serviço</th><th>Profissional</th><th>Cliente</th><th>Preço</th><th>Status</th><th></th>
</tr></thead>
<tbody>
<?php foreach ($agendamentos as $a): ?>
<tr>
<td><?= e(date('d/m/Y H:i', strtotime($a['inicio']))) ?></td>
<td><?= e($a['servico_nome']) ?></td>
<td><?= e($a['profissional_nome']) ?></td>
<td><?= e($a['cliente_nome']) ?></td>
<td><?= e(format_money((float)$a['preco'])) ?></td>
<td><?= e($a['status']) ?></td>
<td class="text-end">
<?php if (in_array($role, ['admin','profissional']) && $a['status']!=='cancelado'): ?>
<form method="post" action="/agendamentos/confirmar/<?= e((string)$a['id']) ?>" class="d-inline">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e(\App\Core\Auth::csrfToken()) ?>">
<button class="btn btn-sm btn-success">Confirmar</button>
</form>
<?php endif; ?>
<?php if (in_array($role, ['admin','cliente']) && $a['status']!=='cancelado'): ?>
<a class="btn btn-sm btn-outline-secondary" href="/agendamentos/remarcar/<?= e((string)$a['id']) ?>">Remarcar</a>
<?php endif; ?>
<?php if (in_array($role, ['admin','profissional','cliente']) && $a['status']!=='cancelado'): ?>
<form method="post" action="/agendamentos/cancelar/<?= e((string)$a['id']) ?>" class="d-inline">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e(\App\Core\Auth::csrfToken()) ?>">
<button class="btn btn-sm btn-outline-danger">Cancelar</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php use function App\Core\e; ?>
<div class="card">
<div class="card-body">
<h1 class="h5 mb-3">Novo Agendamento</h1>
<form method="post" action="/agendamentos/novo">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Serviço</label>
<select class="form-select" name="servico_id" required>
<option value="">Selecione...</option>
<?php foreach ($servicos as $s): ?>
<option value="<?= e((string)$s['id']) ?>"><?= e($s['nome']) ?> — <?= e((string)$s['duracao_min']) ?> min</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Profissional</label>
<select class="form-select" name="profissional_id" required>
<option value="">Selecione...</option>
<?php foreach ($profissionais as $p): ?>
<option value="<?= e((string)$p['id']) ?>"><?= e($p['nome']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Cliente</label>
<select class="form-select" name="cliente_id" required>
<option value="">Selecione...</option>
<?php foreach ($clientes as $c): ?>
<option value="<?= e((string)$c['id']) ?>"><?= e($c['nome']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Data</label>
<input type="date" name="data" class="form-control" required min="<?= e(date('Y-m-d')) ?>">
</div>
<div class="col-md-4">
<label class="form-label">Hora</label>
<input type="time" name="hora" class="form-control" required step="900">
</div>
<div class="col-12">
<label class="form-label">Notas</label>
<textarea name="notas" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary">Criar</button>
<a href="/agendamentos" class="btn btn-secondary">Voltar</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; ?>
<div class="card">
<div class="card-body">
<h1 class="h5 mb-3">Remarcar Agendamento</h1>
<p class="text-muted">Atual: <?= e(date('d/m/Y H:i', strtotime($agendamento['inicio']))) ?> — <?= e(date('H:i', strtotime($agendamento['fim']))) ?></p>
<form method="post" action="/agendamentos/remarcar/<?= e((string)$agendamento['id']) ?>">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nova Data</label>
<input type="date" name="data" class="form-control" required min="<?= e(date('Y-m-d')) ?>">
</div>
<div class="col-md-6">
<label class="form-label">Nova Hora</label>
<input type="time" name="hora" class="form-control" required step="900">
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary">Remarcar</button>
<a href="/agendamentos" class="btn btn-secondary">Voltar</a>
</div>
</form>
</div>
</div>
<?php use function App\Core\e; use function App\Core\format_money; ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4">Relatórios</h1>
</div>
<form class="row g-3 mb-3">
<div class="col-md-3">
<label class="form-label">De</label>
<input class="form-control" type="date" name="de" value="<?= e($from) ?>">
</div>
<div class="col-md-3">
<label class="form-label">Até</label>
<input class="form-control" type="date" name="ate" value="<?= e($to) ?>">
</div>
<div class="col-md-3 align-self-end">
<button class="btn btn-primary">Filtrar</button>
</div>
</form>
<div class="row g-3">
<div class="col-md-4">
<div class="card border-0 shadow-sm"><div class="card-body">
<div class="text-muted small">Total de Atendimentos</div>
<div class="display-6"><?= e((string)$totalAtend) ?></div>
</div></div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm"><div class="card-body">
<div class="text-muted small">Faturamento</div>
<div class="display-6"><?= e(format_money((float)$faturamento)) ?></div>
</div></div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Serviços mais vendidos</div>
<div class="table-responsive">
<table class="table mb-0">
<thead><tr><th>Serviço</th><th>Qtd</th><th>Total</th></tr></thead>
<tbody>
<?php foreach ($topServicos as $s): ?>
<tr>
<td><?= e($s['nome']) ?></td>
<td><?= e((string)$s['qtd']) ?></td>
<td><?= e(format_money((float)$s['total'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php use function App\Core\e; use function App\Core\format_money; ?>
<h1 class="h4 mb-3">Assinaturas</h1>
<div class="row g-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">Assinatura Atual</div>
<div class="card-body">
<?php if ($sub): ?>
<div><strong>Plano:</strong> <?= e($sub['plano_nome']) ?></div>
<div><strong>Status:</strong> <?= e($sub['status']) ?></div>
<div><strong>Validade:</strong> <?= e(date('d/m/Y', strtotime($sub['validade']))) ?></div>
<div><strong>Limites:</strong> <?= e((string)$sub['limite_profissionais']) ?> prof | <?= e((string)$sub['limite_agendamentos_mensal']) ?> agend/mês</div>
<?php else: ?>
<div class="text-muted">Sem assinatura ativa</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Planos</div>
<div class="card-body">
<form method="post" action="/assinaturas/ativar">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-3">
<?php foreach ($planos as $p): ?>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="plano_id" id="plano_<?= e((string)$p['id']) ?>" value="<?= e((string)$p['id']) ?>" required>
<label class="form-check-label" for="plano_<?= e((string)$p['id']) ?>">
<?= e($p['nome']) ?> — <?= e(format_money((float)$p['preco'])) ?> — <?= e((string)$p['limite_profissionais']) ?> prof — <?= e((string)$p['limite_agendamentos_mensal']) ?> ag/mês
</label>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="mt-3">
<button class="btn btn-primary">Ativar/Renovar (30 dias)</button>
</div>
</form>
</div>
</div>
</div>
</div>
<?php use function App\Core\e; ?>
<h1 class="h4 mb-3">Configurações</h1>
<div class="row g-3">
<div class="col-lg-6">
<div class="card">
<div class="card-header">Horários de Funcionamento</div>
<div class="card-body">
<form method="post" action="/configuracoes/horarios">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-2">
<?php
$dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb'];
foreach ($hf as $h):
?>
<div class="col-12 border rounded p-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="fechado_<?= e((string)$h['weekday']) ?>" id="fechado_<?= e((string)$h['weekday']) ?>" <?= $h['fechado'] ? 'checked':'' ?>>
<label class="form-check-label" for="fechado_<?= e((string)$h['weekday']) ?>"><?= e($dias[$h['weekday']]) ?> fechado</label>
</div>
<div class="row mt-2">
<div class="col">
<label class="form-label">Abre</label>
<input type="time" class="form-control" name="abre_<?= e((string)$h['weekday']) ?>" value="<?= e($h['abre']) ?>">
</div>
<div class="col">
<label class="form-label">Fecha</label>
<input type="time" class="form-control" name="fecha_<?= e((string)$h['weekday']) ?>" value="<?= e($h['fecha']) ?>">
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="mt-3">
<button class="btn btn-primary">Salvar</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header">Folgas/Feriados</div>
<div class="card-body">
<form method="post" action="/configuracoes/folgas">
<input type="hidden" name="<?= e(CSRF_TOKEN_KEY) ?>" value="<?= e($csrf) ?>">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Profissional (opcional)</label>
<select class="form-select" name="profissional_id">
<option value="">Barbearia inteira</option>
<?php foreach ($profissionais as $p): ?>
<option value="<?= e((string)$p['id']) ?>"><?= e($p['nome']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-6">
<label class="form-label">Data</label>
<input type="date" name="data" class="form-control" required>
</div>
<div class="col-6">
<label class="form-label">Motivo</label>
<input type="text" name="motivo" class="form-control">
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary">Registrar</button>
</div>
</form>
</div>
</div>
</div>
</div>
/* Estilos básicos */
body { background-color: #f8f9fa; }
.card { border-radius: .5rem; }
document.addEventListener('DOMContentLoaded', () => {
// JS básico se necessário
});
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../app/bootstrap.php';
use App\Core\Database;
$db = Database::conn();
// Expira assinaturas vencidas
$db->exec("UPDATE assinaturas SET status='expirada' WHERE validade < CURDATE() AND status='ativa'");
// Suspende se desejar por regras (exemplo: sem pagamento)
# $db->exec("UPDATE assinaturas SET status='suspensa' WHERE ...");
echo "Subscriptions checked at " . date('c') . PHP_EOL;
Observações importantes:
Este é um sistema completo inicial, pronto para rodar e evoluir. Você pode estender facilmente, adicionando:
Quer que eu inclua uma página pública de agendamento (sem login) por barbearia, com link único e seleção dinâmica de horários disponíveis?
To configure the generation, complete these steps:
faça todas as implemeteções necessario e seja extremamente funcional o projeto ntegração com whatsapp usando whatsapp-web-js e socket.io para gerenciamenteo de sessãoes traga um modelo moderno
/* ...restante do arquivo permanece igual... */
<?php
declare(strict_types=1);
/* Ajuste para o seu ambiente */
const DB_DSN = 'mysql:host=localhost;dbname=barbersaas;charset=utf8mb4';
const DB_USER = 'root';
const DB_PASS = '';
const APP_NAME = 'BarberSaaS';
const APP_URL = 'http://localhost'; // sem barra no final
const TIMEZONE = 'America/Sao_Paulo';
/* Integração WhatsApp (Node) */
const WHATSAPP_NODE_URL = 'http://localhost:3001'; // URL do microserviço Node (whatsapp-web-js + socket.io)
/* E-mail/WhatsApp (stubs legacy - mantidos p/ compatibilidade) */
const MAIL_FROM = 'no-reply@seusite.com';
const WHATSAPP_API_URL = ''; // não usado, substituído por WHATSAPP_NODE_URL
/* Políticas */
const CANCELLATION_POLICY_HOURS = 2; // sem multa até 2h antes
const RESCHEDULE_POLICY_HOURS = 2;
/* Segurança */
const CSRF_TOKEN_KEY = '_csrf';
const CSRF_TTL_SECONDS = 3600;
/* Uploads */
const UPLOAD_DIR = __DIR__ . '/../../public/uploads';
const MAX_UPLOAD_MB = 5;
<?php
declare(strict_types=1);
use App\Core\Router;
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/bootstrap.php';
$router = new Router();
/* Public routes */
$router->get('/', 'DashboardController@index');
$router->get('/login', 'AuthController@login');
$router->post('/login', 'AuthController@loginPost');
$router->get('/logout', 'AuthController@logout');
$router->get('/register', 'AuthController@register');
$router->post('/register', 'AuthController@registerPost');
/* Auth-protected routes */
$router->get('/dashboard', 'DashboardController@index');
/* Serviços */
$router->get('/servicos', 'ServicosController@index');
$router->get('/servicos/novo', 'ServicosController@create');
$router->post('/servicos/novo', 'ServicosController@store');
$router->get('/servicos/editar/{id}', 'ServicosController@edit');
$router->post('/servicos/editar/{id}', 'ServicosController@update');
$router->post('/servicos/delete/{id}', 'ServicosController@destroy');
/* Profissionais */
$router->get('/profissionais', 'ProfissionaisController@index');
$router->get('/profissionais/novo', 'ProfissionaisController@create');
$router->post('/profissionais/novo', 'ProfissionaisController@store');
$router->get('/profissionais/editar/{id}', 'ProfissionaisController@edit');
$router->post('/profissionais/editar/{id}', 'ProfissionaisController@update');
$router->post('/profissionais/delete/{id}', 'ProfissionaisController@destroy');
/* Clientes */
$router->get('/clientes', 'ClientesController@index');
$router->get('/clientes/novo', 'ClientesController@create');
$router->post('/clientes/novo', 'ClientesController@store');
$router->get('/clientes/editar/{id}', 'ClientesController@edit');
$router->post('/clientes/editar/{id}', 'ClientesController@update');
$router->post('/clientes/delete/{id}', 'ClientesController@destroy');
/* Agendamentos */
$router->get('/agendamentos', 'AgendamentosController@index');
$router->get('/agendamentos/novo', 'AgendamentosController@create');
$router->post('/agendamentos/novo', 'AgendamentosController@store');
$router->post('/agendamentos/confirmar/{id}', 'AgendamentosController@confirm');
$router->post('/agendamentos/cancelar/{id}', 'AgendamentosController@cancel');
$router->get('/agendamentos/remarcar/{id}', 'AgendamentosController@rescheduleForm');
$router->post('/agendamentos/remarcar/{id}', 'AgendamentosController@reschedule');
/* Relatórios */
$router->get('/relatorios', 'RelatoriosController@index');
/* Assinaturas */
$router->get('/assinaturas', 'AssinaturasController@index');
$router->post('/assinaturas/ativar', 'AssinaturasController@activate');
/* Configurações da Barbearia */
$router->get('/configuracoes', 'BarbeariaController@index');
$router->post('/configuracoes/horarios', 'BarbeariaController@saveHorarios');
$router->post('/configuracoes/folgas', 'BarbeariaController@saveFolgas');
/* Integrações (WhatsApp) */
$router->get('/integracoes/whatsapp', 'IntegracoesController@whatsapp');
$router->dispatch();
<?php
namespace App\Services;
class NotificationService
{
public static function sendEmail(string $to, string $subject, string $html): bool {
// Stub simples usando mail(). Substitua por PHPMailer/SMTP em produção.
$headers = "From: " . MAIL_FROM . "\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
$ok = @mail($to, $subject, $html, $headers);
self::log('email', json_encode(compact('to','subject','ok')));
return $ok;
}
public static function sendWhatsApp(string $phone, string $message, int $barbeariaId): bool {
// Envia via microserviço Node (whatsapp-web-js)
$to = self::normalizePhone($phone);
if (!$to) {
self::log('whatsapp', 'Telefone inválido: ' . $phone);
return false;
}
$payload = json_encode([
'barbeariaId' => $barbeariaId,
'to' => $to,
'message' => $message,
]);
$ch = curl_init(WHATSAPP_NODE_URL . '/message/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$res = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
$ok = $http >= 200 && $http < 300;
self::log('whatsapp', 'HTTP ' . $http . ' res=' . (string)$res . ' err=' . (string)$err);
return $ok;
}
private static function normalizePhone(string $raw): ?string {
// Remove não-dígitos
$digits = preg_replace('/\D+/', '', $raw);
if (!$digits) return null;
// Se não tiver DDI, assume Brasil 55
if (strlen($digits) < 12) {
$digits = '55' . $digits;
}
return $digits;
}
private static function log(string $channel, string $msg): void {
$dir = __DIR__ . '/../../storage/logs';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
error_log('['.date('c')."] [$channel] $msg\n", 3, $dir.'/notifications.log');
}
}
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
class IntegracoesController extends Controller
{
public function whatsapp(): void {
$this->requireAuth(['admin']);
$csrf = \App\Core\Auth::csrfToken();
$barbeariaId = Auth::user()['barbearia_id'];
$this->render('integracoes/whatsapp', [
'csrf' => $csrf,
'barbeariaId' => $barbeariaId,
'nodeUrl' => WHATSAPP_NODE_URL,
]);
}
}
<?php use App\Core\Auth; use function App\Core\e; $user = Auth::user(); ?>
<nav class="navbar navbar-expand-lg navbar-dark bg-gradient-primary shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="/dashboard"><?= e(APP_NAME) ?></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div id="nav" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<?php if ($user): ?>
<li class="nav-item"><a href="/agendamentos" class="nav-link">Agendamentos</a></li>
<?php if ($user['role']==='admin'): ?>
<li class="nav-item"><a href="/servicos" class="nav-link">Serviços</a></li>
<li class="nav-item"><a href="/profissionais" class="nav-link">Profissionais</a></li>
<li class="nav-item"><a href="/clientes" class="nav-link">Clientes</a></li>
<li class="nav-item"><a href="/relatorios" class="nav-link">Relatórios</a></li>
<li class="nav-item"><a href="/assinaturas" class="nav-link">Assinaturas</a></li>
<li class="nav-item"><a href="/configuracoes" class="nav-link">Configurações</a></li>
<li class="nav-item"><a href="/integracoes/whatsapp" class="nav-link">Integrações</a></li>
<?php endif; ?>
<?php endif; ?>
</ul>
<ul class="navbar-nav">
<?php if ($user): ?>
<li class="nav-item">
<span class="navbar-text me-3"><?= e($user['nome']) ?> (<?= e($user['role']) ?>)</span>
</li>
<li class="nav-item"><a href="/logout" class="btn btn-outline-light btn-sm">Sair</a></li>
<?php else: ?>
<li class="nav-item"><a href="/login" class="nav-link">Entrar</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
<?php use function App\Core\e; ?>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h5 mb-0">Integração WhatsApp</h1>
<span class="badge bg-secondary" id="wa-status">Carregando...</span>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="p-3 rounded border text-center" style="min-height: 320px;">
<div id="qr-wrap" class="d-flex flex-column align-items-center justify-content-center" style="min-height: 280px;">
<div class="text-muted">QR Code aparecerá aqui</div>
</div>
<div class="small text-muted mt-2">Abra o WhatsApp > Configurações > Dispositivos Conectados > Conectar um dispositivo</div>
</div>
<div class="d-flex gap-2 mt-3">
<button id="btn-connect" class="btn btn-primary">Conectar</button>
<button id="btn-disconnect" class="btn btn-outline-danger">Desconectar</button>
</div>
</div>
<div class="col-md-6">
<h6 class="text-muted">Teste de Envio</h6>
<form id="form-test" class="row g-2">
<div class="col-12">
<label class="form-label">Telefone do cliente (com DDD)</label>
<input type="text" class="form-control" id="test-phone" placeholder="5599999999999 ou 11 99999-9999">
</div>
<div class="col-12">
<label class="form-label">Mensagem</label>
<textarea id="test-message" class="form-control" rows="3">Olá! Seu agendamento foi criado com sucesso. Atenciosamente, <?= e(APP_NAME) ?>.</textarea>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-success" id="btn-send">Enviar Mensagem</button>
<span class="small text-muted" id="send-feedback"></span>
</div>
</form>
<hr>
<div class="small">
<strong>Dicas:</strong>
<ul class="mb-0">
<li>Mantenha a sessão conectada para envios automáticos.</li>
<li>Se o QR expirar, clique em Conectar novamente.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
const barbeariaId = <?= json_encode((int)$barbeariaId) ?>;
const NODE_URL = <?= json_encode($nodeUrl) ?>;
const statusEl = document.getElementById('wa-status');
const qrWrap = document.getElementById('qr-wrap');
const btnConnect = document.getElementById('btn-connect');
const btnDisconnect = document.getElementById('btn-disconnect');
const btnSend = document.getElementById('btn-send');
const phoneInput = document.getElementById('test-phone');
const msgInput = document.getElementById('test-message');
const feedback = document.getElementById('send-feedback');
function setStatus(s, cls='secondary') {
statusEl.className = 'badge bg-' + cls;
statusEl.textContent = s;
}
function setQR(src) {
qrWrap.innerHTML = '';
const img = new Image();
img.src = src;
img.alt = 'QR Code';
img.style.maxWidth = '260px';
img.className = 'img-fluid';
qrWrap.appendChild(img);
}
function clearQR() {
qrWrap.innerHTML = '<div class="text-muted">QR Code aparecerá aqui</div>';
}
// Carrega socket.io do servidor Node
const s = document.createElement('script');
s.src = NODE_URL + '/socket.io/socket.io.js';
s.onload = () => {
const socket = io(NODE_URL, { transports: ['websocket'], query: { barbeariaId } });
socket.on('connect', () => {
setStatus('Conectado ao hub', 'info');
});
socket.on('ready', () => {
setStatus('Sessão pronta', 'success');
clearQR();
});
socket.on('authenticated', () => {
setStatus('Autenticado', 'success');
});
socket.on('qr', (data) => {
setStatus('Escaneie o QR', 'warning');
if (data && data.src) setQR(data.src);
});
socket.on('disconnected', () => {
setStatus('Desconectado', 'secondary');
clearQR();
});
socket.on('auth_failure', (m) => {
setStatus('Falha de autenticação', 'danger');
});
socket.on('error', (m) => {
setStatus('Erro', 'danger');
});
btnConnect.addEventListener('click', async () => {
await fetch(NODE_URL + '/session/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barbeariaId })
});
});
btnDisconnect.addEventListener('click', async () => {
await fetch(NODE_URL + '/session/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barbeariaId })
});
});
btnSend.addEventListener('click', async (e) => {
e.preventDefault();
feedback.textContent = 'Enviando...';
const to = phoneInput.value.trim();
const message = msgInput.value.trim();
const res = await fetch(NODE_URL + '/message/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barbeariaId, to, message })
});
if (res.ok) {
feedback.textContent = 'Enviado!';
feedback.className = 'small text-success';
} else {
const t = await res.text();
feedback.textContent = 'Falha: ' + t;
feedback.className = 'small text-danger';
}
});
// Status inicial
fetch(NODE_URL + '/session/status/' + barbeariaId)
.then(r => r.json())
.then(j => {
if (j.ready) { setStatus('Sessão pronta', 'success'); }
else { setStatus(j.status || 'Sem sessão', 'secondary'); }
})
.catch(() => setStatus('Indisponível', 'danger'));
};
document.body.appendChild(s);
})();
</script>
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Auth;
use App\Core\Database;
use App\Models\Agendamento;
use App\Models\Servico;
use App\Models\Profissional;
use App\Models\Cliente;
use App\Services\NotificationService;
use App\Services\SubscriptionService;
use DateTimeImmutable;
use function App\Core\set_flash;
class AgendamentosController extends Controller
{
public function index(): void {
$this->requireAuth(['admin','profissional','cliente']);
$user = Auth::user();
$ag = new Agendamento();
$list = $ag->all($user['barbearia_id'],
$user['role']==='profissional' ? $this->getProfIdByUser($user['id']) : null,
$user['role']==='cliente' ? $this->getClienteIdByUser($user['id']) : null
);
$this->render('agendamentos/index', ['agendamentos' => $list, 'role' => $user['role']]);
}
public function create(): void {
$this->requireAuth(['admin','cliente']);
$user = Auth::user();
$csrf = Auth::csrfToken();
$servicos = (new Servico())->all($user['barbearia_id']);
$profissionais = (new Profissional())->all($user['barbearia_id']);
$clientes = (new Cliente())->all($user['barbearia_id']);
$this->render('agendamentos/form', compact('csrf','servicos','profissionais','clientes'));
}
public function store(): void {
$this->requireAuth(['admin','cliente']);
$this->csrf();
$user = Auth::user();
if (!SubscriptionService::canCreateAppointment($user['barbearia_id'])) {
set_flash('error', 'Limite de agendamentos do seu plano foi atingido este mês.');
$this->redirect('/agendamentos');
}
$servicoId = (int)($_POST['servico_id'] ?? 0);
$profissionalId = (int)($_POST['profissional_id'] ?? 0);
$clienteId = (int)($_POST['cliente_id'] ?? 0);
$data = trim($_POST['data'] ?? '');
$hora = trim($_POST['hora'] ?? '');
$notas = trim($_POST['notas'] ?? '');
$db = Database::conn();
// obter duração/preço do serviço
$stmt = $db->prepare("SELECT duracao_min, preco FROM servicos WHERE id=:sid AND barbearia_id=:bid");
$stmt->execute([':sid' => $servicoId, ':bid' => $user['barbearia_id']]);
$serv = $stmt->fetch();
if (!$serv) { set_flash('error','Serviço inválido.'); $this->redirect('/agendamentos/novo'); }
$inicio = new DateTimeImmutable($data.' '.$hora.':00');
$fim = $inicio->modify('+' . (int)$serv['duracao_min'] . ' minutes');
$ag = new Agendamento();
if (!$ag->isSlotAvailable($user['barbearia_id'], $profissionalId, $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'))) {
set_flash('error', 'Horário indisponível ou fora do expediente.');
$this->redirect('/agendamentos/novo');
}
$id = $ag->create([
'barbearia_id' => $user['barbearia_id'],
'servico_id' => $servicoId,
'profissional_id' => $profissionalId,
'cliente_id' => $clienteId,
'inicio' => $inicio->format('Y-m-d H:i:s'),
'fim' => $fim->format('Y-m-d H:i:s'),
'preco' => (float)$serv['preco'],
'status' => 'pendente',
'notas' => $notas,
'created_by' => $user['id'],
]);
// Busca telefone e email do cliente para notificações
$cStmt = $db->prepare("SELECT email, telefone, nome FROM clientes WHERE id = :cid AND barbearia_id = :bid");
$cStmt->execute([':cid' => $clienteId, ':bid' => $user['barbearia_id']]);
$cliente = $cStmt->fetch();
if ($cliente && !empty($cliente['email'])) {
@NotificationService::sendEmail($cliente['email'], 'Novo agendamento', '<p>Seu agendamento foi criado para ' . $inicio->format('d/m/Y H:i') . '.</p>');
}
if ($cliente && !empty($cliente['telefone'])) {
@NotificationService::sendWhatsApp($cliente['telefone'], 'Olá ' . ($cliente['nome'] ?? '') . '! Seu agendamento foi criado para ' . $inicio->format('d/m/Y H:i') . '.', (int)$user['barbearia_id']);
}
set_flash('success', 'Agendamento criado.');
$this->redirect('/agendamentos');
}
public function confirm($id): void {
$this->requireAuth(['admin','profissional']);
$this->csrf();
$user = Auth::user();
(new Agendamento())->updateStatus((int)$id, $user['barbearia_id'], 'confirmado');
set_flash('success', 'Agendamento confirmado.');
$this->redirect('/agendamentos');
}
public function cancel($id): void {
$this->requireAuth(['admin','profissional','cliente']);
$this->csrf();
$user = Auth::user();
$ag = (new Agendamento())->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
$diffHours = (strtotime($ag['inicio']) - time()) / 3600;
if ($diffHours < CANCELLATION_POLICY_HOURS && $user['role'] === 'cliente') {
set_flash('error', 'Cancelamento não permitido dentro da política.');
$this->redirect('/agendamentos');
}
(new Agendamento())->updateStatus((int)$id, $user['barbearia_id'], 'cancelado');
// notificar cliente
$db = Database::conn();
$cStmt = $db->prepare("SELECT c.email, c.telefone, c.nome FROM agendamentos a JOIN clientes c ON c.id=a.cliente_id WHERE a.id=:id AND a.barbearia_id=:bid");
$cStmt->execute([':id' => (int)$id, ':bid' => $user['barbearia_id']]);
$cliente = $cStmt->fetch();
if ($cliente && !empty($cliente['email'])) {
@\App\Services\NotificationService::sendEmail($cliente['email'], 'Agendamento cancelado', '<p>Seu agendamento foi cancelado.</p>');
}
if ($cliente && !empty($cliente['telefone'])) {
@\App\Services\NotificationService::sendWhatsApp($cliente['telefone'], 'Seu agendamento foi cancelado.', (int)$user['barbearia_id']);
}
set_flash('success', 'Agendamento cancelado.');
$this->redirect('/agendamentos');
}
public function rescheduleForm($id): void {
$this->requireAuth(['admin','cliente']);
$user = Auth::user();
$ag = (new Agendamento())->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
$csrf = Auth::csrfToken();
$this->render('agendamentos/form-remarcar', ['csrf'=>$csrf,'agendamento'=>$ag]);
}
public function reschedule($id): void {
$this->requireAuth(['admin','cliente']);
$this->csrf();
$user = Auth::user();
$agModel = new Agendamento();
$ag = $agModel->find((int)$id, $user['barbearia_id']);
if (!$ag) { http_response_code(404); exit('Não encontrado'); }
$data = trim($_POST['data'] ?? '');
$hora = trim($_POST['hora'] ?? '');
$inicio = new \DateTimeImmutable($data.' '.$hora.':00');
$dur = (strtotime($ag['fim']) - strtotime($ag['inicio'])) / 60;
$fim = $inicio->modify('+' . (int)$dur . ' minutes');
$diffHours = (strtotime($ag['inicio']) - time()) / 3600;
if ($diffHours < RESCHEDULE_POLICY_HOURS && $user['role'] === 'cliente') {
set_flash('error', 'Reagendamento não permitido dentro da política.');
$this->redirect('/agendamentos');
}
if (!$agModel->isSlotAvailable($user['barbearia_id'], (int)$ag['profissional_id'], $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'))) {
set_flash('error', 'Novo horário indisponível.');
$this->redirect('/agendamentos/remarcar/'.$id);
}
$agModel->reschedule((int)$id, $user['barbearia_id'], $inicio->format('Y-m-d H:i:s'), $fim->format('Y-m-d H:i:s'));
// notificar cliente
$db = Database::conn();
$cStmt = $db->prepare("SELECT c.email, c.telefone, c.nome FROM agendamentos a JOIN clientes c ON c.id=a.cliente_id WHERE a.id=:id AND a.barbearia_id=:bid");
$cStmt->execute([':id' => (int)$id, ':bid' => $user['barbearia_id']]);
$cliente = $cStmt->fetch();
if ($cliente && !empty($cliente['email'])) {
@\App\Services\NotificationService::sendEmail($cliente['email'], 'Agendamento remarcado', '<p>Seu agendamento foi remarcado para ' . $inicio->format('d/m/Y H:i') . '.</p>');
}
if ($cliente && !empty($cliente['telefone'])) {
@\App\Services\NotificationService::sendWhatsApp($cliente['telefone'], 'Seu agendamento foi remarcado para ' . $inicio->format('d/m/Y H:i') . '.', (int)$user['barbearia_id']);
}
set_flash('success', 'Agendamento remarcado.');
$this->redirect('/agendamentos');
}
private function getProfIdByUser(int $userId): ?int {
$db = \App\Core\Database::conn();
$stmt = $db->prepare("SELECT id FROM profissionais WHERE user_id = :uid");
$stmt->execute([':uid' => $userId]);
$id = $stmt->fetchColumn();
return $id ? (int)$id : null;
}
private function getClienteIdByUser(int $userId): ?int {
$db = \App\Core\Database::conn();
$stmt = $db->prepare("SELECT id FROM clientes WHERE user_id = :uid");
$stmt->execute([':uid' => $userId]);
$id = $stmt->fetchColumn();
return $id ? (int)$id : null;
}
}
/* Modern look */
:root {
--primary: #6f42c1;
--primary-600: #5b33aa;
--gradient: linear-gradient(90deg, #6f42c1, #9b59b6);
}
body { background-color: #f4f6f8; }
.bg-gradient-primary { background: var(--gradient); }
.navbar-brand { letter-spacing: .3px; }
.card { border-radius: .8rem; }
.btn-primary { background-color: var(--primary); border-color: var(--primary); }
.btn-primary:hover { background-color: var(--primary-600); border-color: var(--primary-600); }
.badge { border-radius: .5rem; padding: .5em .75em; }
{
"name": "barbersaas-whatsapp-service",
"version": "1.0.0",
"description": "WhatsApp microservice for BarberSaaS using whatsapp-web-js and socket.io",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.7.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"whatsapp-web.js": "^1.25.0"
}
}
import express from 'express';
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import cors from 'cors';
import QRCode from 'qrcode';
import { Client, LocalAuth } from 'whatsapp-web.js';
const PORT = process.env.PORT || 3001;
const ORIGINS = process.env.ALLOW_ORIGINS ? process.env.ALLOW_ORIGINS.split(',') : ['http://localhost']; // ajuste conforme ambiente
const app = express();
const server = http.createServer(app);
const io = new SocketIOServer(server, {
cors: { origin: ORIGINS, methods: ['GET','POST'] }
});
app.use(cors({ origin: ORIGINS }));
app.use(express.json());
/**
* Sessions: chaveadas por barbeariaId
* Estrutura: { client, ready, status }
*/
const sessions = new Map();
function roomFor(barbeariaId) {
return `barb_${barbeariaId}`;
}
function createSession(barbeariaId) {
const key = String(barbeariaId);
if (sessions.has(key)) return sessions.get(key);
const client = new Client({
authStrategy: new LocalAuth({ clientId: `barber_${key}` }),
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
]
}
});
const state = { client, ready: false, status: 'initializing' };
sessions.set(key, state);
client.on('qr', async (qr) => {
state.status = 'qr';
try {
const src = await QRCode.toDataURL(qr);
io.to(roomFor(key)).emit('qr', { src });
} catch (e) {
io.to(roomFor(key)).emit('error', { message: 'QR generation failed' });
}
});
client.on('authenticated', () => {
state.status = 'authenticated';
io.to(roomFor(key)).emit('authenticated');
});
client.on('ready', () => {
state.ready = true;
state.status = 'ready';
io.to(roomFor(key)).emit('ready');
});
client.on('disconnected', (reason) => {
state.ready = false;
state.status = 'disconnected';
io.to(roomFor(key)).emit('disconnected', { reason });
// tentar reiniciar sessão
client.initialize().catch(() => {});
});
client.on('auth_failure', (msg) => {
state.ready = false;
state.status = 'auth_failure';
io.to(roomFor(key)).emit('auth_failure', { message: msg });
});
client.initialize().catch((e) => {
state.status = 'error';
io.to(roomFor(key)).emit('error', { message: e?.message || 'init error' });
});
return state;
}
// Socket.io
io.on('connection', (socket) => {
const barbeariaId = socket.handshake.query?.barbeariaId;
if (barbeariaId) {
socket.join(roomFor(barbeariaId));
}
socket.on('disconnect', () => {});
});
// REST API
app.post('/session/init', (req, res) => {
const { barbeariaId } = req.body || {};
if (!barbeariaId) return res.status(400).send('barbeariaId is required');
const s = createSession(barbeariaId);
return res.json({ status: s.status, ready: s.ready });
});
app.get('/session/status/:barbeariaId', (req, res) => {
const key = String(req.params.barbeariaId);
const s = sessions.get(key);
if (!s) return res.json({ ready: false, status: 'no_session' });
return res.json({ ready: !!s.ready, status: s.status });
});
app.post('/session/logout', async (req, res) => {
const { barbeariaId } = req.body || {};
const key = String(barbeariaId);
const s = sessions.get(key);
if (!s) return res.status(200).json({ ok: true, message: 'no session' });
try {
await s.client.logout();
sessions.delete(key);
io.to(roomFor(key)).emit('disconnected', { reason: 'logout' });
return res.json({ ok: true });
} catch (e) {
return res.status(500).send(e?.message || 'logout error');
}
});
app.post('/message/send', async (req, res) => {
const { barbeariaId, to, message } = req.body || {};
if (!barbeariaId || !to || !message) return res.status(400).send('missing fields');
const key = String(barbeariaId);
const s = sessions.get(key);
if (!s) return res.status(412).send('no session');
if (!s.ready) return res.status(412).send('session not ready');
// whatsapp-web-js precisa do formato com @c.us (números internacionais)
const jid = `${to}@c.us`;
try {
const resp = await s.client.sendMessage(jid, message);
io.to(roomFor(key)).emit('message_sent', { to, id: resp?.id?._serialized || null });
return res.json({ ok: true, id: resp?.id?._serialized || null });
} catch (e) {
return res.status(500).send(e?.message || 'send error');
}
});
server.listen(PORT, () => {
console.log('WhatsApp service listening on :'+PORT);
console.log('Allowed Origins:', ORIGINS);
});
# WhatsApp Service (whatsapp-web-js + socket.io)
Microserviço para integrações WhatsApp do BarberSaaS.
Requisitos:
- Node 18+
- Ambiente com suporte a headless Chrome (Puppeteer). Em produção, configure variáveis e dependências do SO conforme sua VM/contêiner.
Instalação:
1) cd node-whatsapp
2) npm install
3) npm start
Configurações:
- PORT (default 3001)
- ALLOW_ORIGINS (separados por vírgula). Ex: http://localhost,http://seu-dominio.com
Persistência de sessão:
- O whatsapp-web-js usa LocalAuth com clientId por barbearia. Os dados ficam em `.wwebjs_auth` dentro dessa pasta.
Endpoints:
- POST /session/init { barbeariaId }
- GET /session/status/:barbeariaId
- POST /session/logout { barbeariaId }
- POST /message/send { barbeariaId, to, message }
Socket.io:
- O cliente (painel admin) conecta em /socket.io e entra num "room" por barbeariaId
- Eventos: qr, authenticated, ready, disconnected, auth_failure, message_sent, error
Segurança:
- Coloque o serviço atrás de firewall/restrinja origem via ALLOW_ORIGINS
- Opcional: adicione API key e valide nas rotas
Execução com PM2 (opcional):
pm2 start server.js --name barbersaas-wa --env production
\`\`\`
Atualizações visuais leves já foram aplicadas no CSS e Navbar. Caso deseje um tema completo dark/light com Bootstrap utilities e componentes custom, posso expandir.
Como rodar a integração WhatsApp:
1) Suba o PHP + MySQL como já estava (importando o schema e configurando app/config/config.php).
2) Suba o serviço Node:
- cd node-whatsapp
- npm install
- npm start (ou defina PORT e ALLOW_ORIGINS)
3) Ajuste WHATSAPP_NODE_URL em app/config/config.php para apontar para o host/porta do serviço.
4) No painel (como admin), acesse Integrações > WhatsApp: clique em Conectar, escaneie o QR no celular. Status ficará “Sessão pronta”.
Fluxo de Notificações:
- Ao criar, cancelar ou remarcar um agendamento, se o cliente tiver telefone, a mensagem é enviada automaticamente via WhatsApp (se a sessão estiver pronta). Caso não esteja pronta, o envio retorna erro no log.
Notas e próximos passos:
- Recomendo adicionar uma chave de API (ex.: header Authorization) no microserviço para chamadas servidor-servidor a partir do PHP.
- Para múltiplas instâncias/escala, persista sessões em volume compartilhado (LocalAuth) ou migre para outro storage.
- Caso precise enviar templates ricos (imagem, arquivo, botões), o whatsapp-web-js suporta anexos (MessageMedia); posso incluir exemplos.
To configure the generation, complete these steps:
traga-me o zip desse projeto pra que eu possa rodar localmente e testar e fazer mais implementações