auth->requireStaff(); $recentOrders = $this->db->fetchAll( 'SELECT * FROM orders ORDER BY placed_at DESC LIMIT 10' ); $popularItems = $this->db->fetchAll( 'SELECT oi.item_name, SUM(oi.quantity) AS quantity, SUM(oi.line_total_cents) AS sales_cents FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE o.payment_status = ? GROUP BY oi.item_name ORDER BY quantity DESC LIMIT 6', ['paid'] ); $traffic = $this->db->isSqlite() ? $this->db->fetchAll( "SELECT date(created_at, 'localtime') AS day, COUNT(DISTINCT session_id) AS visitors FROM visitor_events WHERE created_at >= datetime('now', '-6 days') GROUP BY date(created_at, 'localtime') ORDER BY day" ) : $this->db->fetchAll( "SELECT DATE(created_at) AS day, COUNT(DISTINCT session_id) AS visitors FROM visitor_events WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 DAY) GROUP BY DATE(created_at) ORDER BY day" ); View::render('admin/dashboard', $this->shared([ 'title' => 'Dashboard', 'adminPage' => 'dashboard', 'stats' => $this->stats->dashboard(), 'recentOrders' => $recentOrders, 'popularItems' => $popularItems, 'traffic' => $traffic, ])); } public function orders(): void { $this->auth->requireStaff(); $status = Security::clean((string) ($_GET['status'] ?? '')); $query = Security::clean((string) ($_GET['q'] ?? '')); $where = ['1 = 1']; $parameters = []; if ($status !== '') { $where[] = 'o.status = ?'; $parameters[] = $status; } if ($query !== '') { $where[] = '(o.order_number LIKE ? OR o.customer_name LIKE ? OR o.customer_email LIKE ?)'; $parameters[] = '%' . $query . '%'; $parameters[] = '%' . $query . '%'; $parameters[] = '%' . $query . '%'; } $orders = $this->db->fetchAll( 'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE ' . implode(' AND ', $where) . ' GROUP BY o.id ORDER BY o.placed_at DESC LIMIT 200', $parameters ); View::render('admin/orders', $this->shared([ 'title' => 'Orders', 'adminPage' => 'orders', 'orders' => $orders, 'statusFilter' => $status, 'query' => $query, ])); } public function orderDetail(): void { $this->auth->requireStaff(); $orderId = (int) ($_GET['id'] ?? 0); $order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]); if ($order === null) { Http::abort(404, 'Order not found.'); } View::render('admin/order-detail', $this->shared([ 'title' => 'Order ' . $order['order_number'], 'adminPage' => 'orders', 'order' => $order, 'items' => $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]), 'events' => $this->db->fetchAll( 'SELECT oe.*, u.full_name AS staff_name FROM order_events oe LEFT JOIN users u ON u.id = oe.user_id WHERE oe.order_id = ? ORDER BY oe.created_at DESC, oe.id DESC', [$orderId] ), 'refunds' => $this->db->fetchAll( 'SELECT r.*, u.full_name AS staff_name FROM refunds r LEFT JOIN users u ON u.id = r.staff_user_id WHERE r.order_id = ? ORDER BY r.created_at DESC', [$orderId] ), ])); } public function updateOrder(): void { Security::verifyCsrf(); $staff = $this->auth->requireStaff(); $orderId = (int) ($_POST['order_id'] ?? 0); $status = Security::clean((string) ($_POST['status'] ?? '')); $allowed = ['paid', 'preparing', 'ready', 'completed', 'cancelled']; if (!in_array($status, $allowed, true)) { Http::flash('error', 'Choose a valid order status.'); Http::redirect('/admin/orders'); } $notes = Security::clean((string) ($_POST['staff_note'] ?? '')); $this->db->execute( 'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [$status, $orderId] ); $this->db->execute( 'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)', [$orderId, (int) $staff['id'], 'status_changed', 'Status changed to ' . $status . ($notes ? '. ' . $notes : '.')] ); Http::flash('success', 'Order status updated.'); Http::redirect('/admin/order?id=' . $orderId); } public function refundOrder(): void { Security::verifyCsrf(); $staff = $this->auth->requireStaff(); $orderId = (int) ($_POST['order_id'] ?? 0); $order = $this->db->fetch( 'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.id = ? GROUP BY o.id', [$orderId] ); if ($order === null) { Http::abort(404, 'Order not found.'); } $amountCents = (int) round(((float) ($_POST['amount'] ?? 0)) * 100); $available = (int) $order['total_cents'] - (int) $order['refunded_cents']; $reason = Security::clean((string) ($_POST['reason'] ?? 'Staff refund')); if ($amountCents <= 0 || $amountCents > $available) { Http::flash('error', 'Refund amount must be greater than zero and no more than the remaining paid amount.'); Http::redirect('/admin/order?id=' . $orderId); } try { $refund = $this->payments->refund((string) $order['payment_reference'], $amountCents, $reason); $this->db->execute( 'INSERT INTO refunds (order_id, staff_user_id, amount_cents, reason, provider_reference) VALUES (?, ?, ?, ?, ?)', [$orderId, (int) $staff['id'], $amountCents, $reason, $refund['reference']] ); $newRefunded = (int) $order['refunded_cents'] + $amountCents; $paymentStatus = $newRefunded >= (int) $order['total_cents'] ? 'refunded' : 'partially_refunded'; $this->db->execute('UPDATE orders SET payment_status = ? WHERE id = ?', [$paymentStatus, $orderId]); $this->db->execute( 'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)', [$orderId, (int) $staff['id'], 'refund_issued', sprintf('$%.2f refunded: %s', $amountCents / 100, $reason)] ); Http::flash('success', 'Refund issued successfully.'); } catch (\Throwable $error) { Http::flash('error', $error->getMessage()); } Http::redirect('/admin/order?id=' . $orderId); } public function menu(): void { $this->auth->requireStaff(); $categories = $this->db->fetchAll('SELECT * FROM menu_categories ORDER BY display_order, name'); $items = $this->db->fetchAll( 'SELECT mi.*, mc.name AS category_name FROM menu_items mi JOIN menu_categories mc ON mc.id = mi.category_id ORDER BY mc.display_order, mi.display_order, mi.name' ); View::render('admin/menu', $this->shared([ 'title' => 'Menu Manager', 'adminPage' => 'menu', 'categories' => $categories, 'items' => $items, ])); } public function saveCategory(): void { Security::verifyCsrf(); $this->auth->requireStaff(); $id = (int) ($_POST['id'] ?? 0); $name = Security::clean((string) ($_POST['name'] ?? '')); if ($name === '') { Http::flash('error', 'Category name is required.'); Http::redirect('/admin/menu'); } $values = [ $name, Security::slug((string) ($_POST['slug'] ?? $name)), Security::clean((string) ($_POST['description'] ?? '')), (int) ($_POST['display_order'] ?? 0), isset($_POST['active']) ? 1 : 0, ]; if ($id > 0) { $values[] = $id; $this->db->execute( 'UPDATE menu_categories SET name = ?, slug = ?, description = ?, display_order = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', $values ); } else { $this->db->execute( 'INSERT INTO menu_categories (name, slug, description, display_order, active) VALUES (?, ?, ?, ?, ?)', $values ); } Http::flash('success', 'Menu category saved.'); Http::redirect('/admin/menu'); } public function saveItem(): void { Security::verifyCsrf(); $this->auth->requireStaff(); $id = (int) ($_POST['id'] ?? 0); $name = Security::clean((string) ($_POST['name'] ?? '')); $categoryId = (int) ($_POST['category_id'] ?? 0); $priceCents = (int) round(((float) ($_POST['price'] ?? 0)) * 100); if ($name === '' || $categoryId < 1 || $priceCents < 0) { Http::flash('error', 'Item name, category, and a valid price are required.'); Http::redirect('/admin/menu'); } $compareAt = trim((string) ($_POST['compare_at_price'] ?? '')) === '' ? null : (int) round(((float) $_POST['compare_at_price']) * 100); $tags = array_values(array_filter(array_map( static fn (string $tag): string => Security::clean($tag), explode(',', (string) ($_POST['dietary_tags'] ?? '')) ))); $values = [ $categoryId, $name, Security::slug((string) ($_POST['slug'] ?? $name)), Security::clean((string) ($_POST['description'] ?? '')), $priceCents, $compareAt, Security::clean((string) ($_POST['image_url'] ?? '')), json_encode($tags, JSON_UNESCAPED_SLASHES), isset($_POST['featured']) ? 1 : 0, isset($_POST['active']) ? 1 : 0, (int) ($_POST['display_order'] ?? 0), ]; if ($id > 0) { $values[] = $id; $this->db->execute( 'UPDATE menu_items SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?, compare_at_cents = ?, image_url = ?, dietary_tags = ?, featured = ?, active = ?, display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', $values ); } else { $this->db->execute( 'INSERT INTO menu_items (category_id, name, slug, description, price_cents, compare_at_cents, image_url, dietary_tags, featured, active, display_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', $values ); } Http::flash('success', 'Menu item saved.'); Http::redirect('/admin/menu'); } public function specials(): void { $this->auth->requireStaff(); View::render('admin/specials', $this->shared([ 'title' => 'Homepage Specials', 'adminPage' => 'specials', 'specials' => $this->db->fetchAll( 'SELECT s.*, mi.name AS item_name FROM specials s LEFT JOIN menu_items mi ON mi.id = s.menu_item_id ORDER BY s.display_order, s.id' ), 'items' => $this->db->fetchAll('SELECT id, name FROM menu_items WHERE active = 1 ORDER BY name'), ])); } public function saveSpecial(): void { Security::verifyCsrf(); $this->auth->requireStaff(); $id = (int) ($_POST['id'] ?? 0); $title = Security::clean((string) ($_POST['title'] ?? '')); if ($title === '') { Http::flash('error', 'Special title is required.'); Http::redirect('/admin/specials'); } $price = trim((string) ($_POST['price'] ?? '')) === '' ? null : (int) round(((float) $_POST['price']) * 100); $values = [ (int) ($_POST['menu_item_id'] ?? 0) ?: null, $title, Security::clean((string) ($_POST['description'] ?? '')), Security::clean((string) ($_POST['badge'] ?? 'Special')), $price, utc_datetime(Security::clean((string) ($_POST['starts_at'] ?? ''))), utc_datetime(Security::clean((string) ($_POST['ends_at'] ?? ''))), isset($_POST['active']) ? 1 : 0, (int) ($_POST['display_order'] ?? 0), ]; if ($id > 0) { $values[] = $id; $this->db->execute( 'UPDATE specials SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?, starts_at = ?, ends_at = ?, active = ?, display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', $values ); } else { $this->db->execute( 'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, starts_at, ends_at, active, display_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', $values ); } Http::flash('success', 'Homepage special saved.'); Http::redirect('/admin/specials'); } public function users(): void { $this->auth->requireAdmin(); $query = Security::clean((string) ($_GET['q'] ?? '')); $parameters = []; $where = '1 = 1'; if ($query !== '') { $where = '(email LIKE ? OR full_name LIKE ? OR phone LIKE ?)'; $parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%']; } View::render('admin/users', $this->shared([ 'title' => 'Users & Staff', 'adminPage' => 'users', 'users' => $this->db->fetchAll("SELECT * FROM users WHERE {$where} ORDER BY role, full_name", $parameters), 'query' => $query, ])); } public function saveUser(): void { Security::verifyCsrf(); $admin = $this->auth->requireAdmin(); $id = (int) ($_POST['id'] ?? 0); $role = Security::clean((string) ($_POST['role'] ?? 'customer')); if (!in_array($role, ['customer', 'staff', 'manager', 'admin'], true)) { $role = 'customer'; } if ($id === (int) $admin['id'] && !isset($_POST['active'])) { Http::flash('error', 'You cannot deactivate your own account.'); Http::redirect('/admin/users'); } if ($id > 0) { $this->db->execute( 'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [ Security::clean((string) ($_POST['full_name'] ?? '')), Security::clean((string) ($_POST['phone'] ?? '')), $role, isset($_POST['active']) ? 1 : 0, isset($_POST['newsletter_opt_in']) ? 1 : 0, $id, ] ); } else { $email = strtolower(Security::clean((string) ($_POST['email'] ?? ''))); $password = (string) ($_POST['password'] ?? ''); if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 10) { Http::flash('error', 'New staff accounts need a valid email and a 10-character password.'); Http::redirect('/admin/users'); } $this->db->execute( 'INSERT INTO users (email, password_hash, full_name, phone, role, active, newsletter_opt_in, email_verified_at) VALUES (?, ?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP)', [ $email, password_hash($password, PASSWORD_DEFAULT), Security::clean((string) ($_POST['full_name'] ?? '')), Security::clean((string) ($_POST['phone'] ?? '')), $role, isset($_POST['newsletter_opt_in']) ? 1 : 0, ] ); } Http::flash('success', 'User account saved.'); Http::redirect('/admin/users'); } public function settings(): void { $this->auth->requireAdmin(); View::render('admin/settings', $this->shared([ 'title' => 'Store Settings', 'adminPage' => 'settings', 'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates ORDER BY jurisdiction, name'), ])); } public function saveSettings(): void { Security::verifyCsrf(); $this->auth->requireAdmin(); $textFields = [ 'app.url', 'business.name', 'business.phone', 'business.email', 'business.street', 'business.city', 'business.state', 'business.postal_code', 'business.hours', 'business.announcement', 'square.environment', 'square.application_id', 'square.location_id', 'mailgun.domain', 'mailgun.from_name', 'mailgun.from_email', 'database.mysql_host', 'database.mysql_database', 'database.mysql_username', ]; foreach ($textFields as $key) { [$group, $field] = explode('.', $key, 2); if (isset($_POST[$group]) && is_array($_POST[$group]) && array_key_exists($field, $_POST[$group])) { $this->config->set($key, trim((string) $_POST[$group][$field])); } } foreach (['square.access_token', 'mailgun.api_key', 'database.mysql_password'] as $secretKey) { [$group, $field] = explode('.', $secretKey, 2); $secret = isset($_POST[$group]) && is_array($_POST[$group]) ? trim((string) ($_POST[$group][$field] ?? '')) : ''; if ($secret !== '') { $this->config->set($secretKey, $secret); } } $ordering = isset($_POST['ordering']) && is_array($_POST['ordering']) ? $_POST['ordering'] : []; $square = isset($_POST['square']) && is_array($_POST['square']) ? $_POST['square'] : []; $mailgun = isset($_POST['mailgun']) && is_array($_POST['mailgun']) ? $_POST['mailgun'] : []; $database = isset($_POST['database']) && is_array($_POST['database']) ? $_POST['database'] : []; $this->config->set('ordering.accepting_orders', isset($ordering['accepting_orders'])); $this->config->set('ordering.pickup_eta_minutes', max(5, (int) ($ordering['pickup_eta_minutes'] ?? 25))); $this->config->set('square.enabled', isset($square['enabled'])); $this->config->set('mailgun.enabled', isset($mailgun['enabled'])); $this->config->set('database.mysql_port', (int) ($database['mysql_port'] ?? 3306)); $this->config->save(); Http::flash('success', 'Store settings saved.'); Http::redirect('/admin/settings'); } public function saveTax(): void { Security::verifyCsrf(); $this->auth->requireAdmin(); $id = (int) ($_POST['id'] ?? 0); $values = [ Security::clean((string) ($_POST['name'] ?? 'Tax')), Security::clean((string) ($_POST['jurisdiction'] ?? 'state')), (int) round(((float) ($_POST['rate'] ?? 0)) * 100), isset($_POST['active']) ? 1 : 0, ]; if ($id > 0) { $values[] = $id; $this->db->execute( 'UPDATE tax_rates SET name = ?, jurisdiction = ?, rate_basis_points = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', $values ); } else { $this->db->execute( 'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points, active) VALUES (?, ?, ?, ?)', $values ); } Http::flash('success', 'Tax rate saved.'); Http::redirect('/admin/settings'); } public function newsletters(): void { $this->auth->requireStaff(); View::render('admin/newsletters', $this->shared([ 'title' => 'Newsletters', 'adminPage' => 'newsletters', 'newsletters' => $this->db->fetchAll( 'SELECT n.*, u.full_name AS author FROM newsletters n LEFT JOIN users u ON u.id = n.created_by ORDER BY n.created_at DESC' ), 'recipientCount' => (int) $this->db->fetch( 'SELECT COUNT(*) AS count FROM users WHERE newsletter_opt_in = 1 AND active = 1' )['count'], ])); } public function sendNewsletter(): void { Security::verifyCsrf(); $staff = $this->auth->requireStaff(); $subject = Security::clean((string) ($_POST['subject'] ?? '')); $content = trim((string) ($_POST['content'] ?? '')); if ($subject === '' || $content === '') { Http::flash('error', 'Newsletter subject and message are required.'); Http::redirect('/admin/newsletters'); } $recipients = $this->db->fetchAll( 'SELECT id, email, full_name FROM users WHERE newsletter_opt_in = 1 AND active = 1 ORDER BY id' ); $sent = 0; foreach ($recipients as $recipient) { $signature = hash_hmac('sha256', (string) $recipient['id'], $this->newsletterKey()); $unsubscribe = rtrim((string) $this->config->get('app.url'), '/') . '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature; $html = '
Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',
' . 'You received this because you joined the Fat Bottom Grille news list. ' . 'Unsubscribe
'; try { $this->mailer->send((string) $recipient['email'], $subject, $html); $sent++; } catch (\Throwable) { continue; } } $this->db->execute( 'INSERT INTO newsletters (subject, content_html, status, recipient_count, created_by, sent_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', [$subject, nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')), 'sent', $sent, (int) $staff['id']] ); Http::flash('success', "Newsletter sent to {$sent} subscribed users."); Http::redirect('/admin/newsletters'); } public function exportMenuPdf(): void { $this->auth->requireStaff(); $categories = $this->db->fetchAll('SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name'); foreach ($categories as &$category) { $category['items'] = $this->db->fetchAll( 'SELECT * FROM menu_items WHERE category_id = ? AND active = 1 ORDER BY display_order, name', [(int) $category['id']] ); } unset($category); (new MenuPdfExporter())->output((string) $this->config->get('business.name'), $categories); } public function exportOrdersCsv(): never { $this->auth->requireStaff(); $orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC'); header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="fat-bottom-orders-' . date('Y-m-d') . '.csv"'); $output = fopen('php://output', 'wb'); fputcsv($output, [ 'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment', 'Subtotal', 'Tax', 'Total', 'Instructions', ]); foreach ($orders as $order) { fputcsv($output, [ $order['order_number'], $order['placed_at'], $order['customer_name'], $order['customer_email'], $order['customer_phone'], $order['status'], $order['payment_status'], number_format((int) $order['subtotal_cents'] / 100, 2, '.', ''), number_format((int) $order['tax_cents'] / 100, 2, '.', ''), number_format((int) $order['total_cents'] / 100, 2, '.', ''), $order['special_instructions'], ]); } fclose($output); exit; } public function migrateMySql(): void { Security::verifyCsrf(); $this->auth->requireAdmin(); try { $result = (new MySqlMigrator($this->config))->migrate(); if (isset($_POST['switch_driver'])) { $this->config->set('database.driver', 'mysql'); $this->config->save(); } Http::flash( 'success', sprintf('Copied %d tables and %d rows to MySQL%s.', $result['tables'], $result['rows'], isset($_POST['switch_driver']) ? ' and enabled MySQL mode' : '') ); } catch (\Throwable $error) { Http::flash('error', $error->getMessage()); } Http::redirect('/admin/settings'); } private function newsletterKey(): string { return hash('sha256', (string) $this->config->get('app.key') . '|newsletter'); } }