Autosave: 20260222-182125
This commit is contained in:
parent
2996ec35e3
commit
5d1e95ef4f
@ -2,64 +2,187 @@
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
$pdo = db();
|
||||
|
||||
$query = "SELECT * FROM loyalty_customers ORDER BY points DESC";
|
||||
$customers_pagination = paginate_query($pdo, $query);
|
||||
$customers = $customers_pagination['data'];
|
||||
// Handle Settings Update
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_settings'])) {
|
||||
$points_per_order = intval($_POST['points_per_order']);
|
||||
$points_for_free_meal = intval($_POST['points_for_free_meal']);
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE loyalty_settings SET points_per_order = ?, points_for_free_meal = ? WHERE id = 1");
|
||||
$stmt->execute([$points_per_order, $points_for_free_meal]);
|
||||
|
||||
$success_msg = "Loyalty settings updated successfully!";
|
||||
}
|
||||
|
||||
// Fetch Settings
|
||||
$stmt = $pdo->query("SELECT * FROM loyalty_settings WHERE id = 1");
|
||||
$settings = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$settings) {
|
||||
// Default fallback if migration failed or empty
|
||||
$settings = ['points_per_order' => 10, 'points_for_free_meal' => 70];
|
||||
}
|
||||
|
||||
// Fetch Customers with Points
|
||||
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$count_stmt = $pdo->query("SELECT COUNT(*) FROM customers WHERE points > 0");
|
||||
$total_customers = $count_stmt->fetchColumn();
|
||||
$total_pages = ceil($total_customers / $limit);
|
||||
|
||||
$query = "SELECT * FROM customers WHERE points > 0 ORDER BY points DESC LIMIT $limit OFFSET $offset";
|
||||
$stmt = $pdo->query($query);
|
||||
$customers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
include 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Loyalty Program</h2>
|
||||
<button class="btn btn-outline-primary" disabled>
|
||||
<i class="bi bi-gear"></i> Settings (Coming Soon)
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#settingsModal">
|
||||
<i class="bi bi-gear-fill me-2"></i> Configure Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<!-- Pagination Controls -->
|
||||
<div class="p-3 border-bottom bg-light">
|
||||
<?php render_pagination_controls($customers_pagination); ?>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Points Balance</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($customers as $customer): ?>
|
||||
<tr>
|
||||
<td class="ps-4 fw-medium">#<?= $customer['id'] ?></td>
|
||||
<td class="fw-bold"><?= htmlspecialchars($customer['name']) ?></td>
|
||||
<td><a href="mailto:<?= htmlspecialchars($customer['email']) ?>" class="text-decoration-none"><?= htmlspecialchars($customer['email']) ?></a></td>
|
||||
<td>
|
||||
<span class="badge bg-warning text-dark border border-warning">
|
||||
<i class="bi bi-star-fill small me-1"></i> <?= $customer['points'] ?> pts
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small"><?= date('M d, Y', strtotime($customer['created_at'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($customers)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">No loyalty members yet.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Bottom Pagination -->
|
||||
<div class="p-3 border-top bg-light">
|
||||
<?php render_pagination_controls($customers_pagination); ?>
|
||||
<?php if (isset($success_msg)): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= htmlspecialchars($success_msg) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted text-uppercase small">Current Configuration</h6>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<div>
|
||||
<span class="d-block text-muted small">Points per Order</span>
|
||||
<span class="fs-4 fw-bold text-primary"><?= $settings['points_per_order'] ?> pts</span>
|
||||
</div>
|
||||
<div class="border-start ps-3">
|
||||
<span class="d-block text-muted small">Free Meal Threshold</span>
|
||||
<span class="fs-4 fw-bold text-success"><?= $settings['points_for_free_meal'] ?> pts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Loyalty Members</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Customer</th>
|
||||
<th>Contact</th>
|
||||
<th>Points Balance</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($customers as $customer): ?>
|
||||
<?php
|
||||
$progress = min(100, ($customer['points'] / $settings['points_for_free_meal']) * 100);
|
||||
$eligible = $customer['points'] >= $settings['points_for_free_meal'];
|
||||
?>
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold"><?= htmlspecialchars($customer['name']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="small"><?= htmlspecialchars($customer['phone'] ?? '-') ?></div>
|
||||
<div class="small text-muted"><?= htmlspecialchars($customer['email'] ?? '') ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-warning text-dark border border-warning me-2" style="width: 60px;">
|
||||
<?= $customer['points'] ?> pts
|
||||
</span>
|
||||
<div class="progress flex-grow-1" style="height: 6px; max-width: 100px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: <?= $progress ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($eligible): ?>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||
<i class="bi bi-gift-fill me-1"></i> Eligible for Free Meal
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">
|
||||
<?= $settings['points_for_free_meal'] - $customer['points'] ?> pts to go
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-muted small"><?= date('M d, Y', strtotime($customer['created_at'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($customers)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">No active loyalty members found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($total_pages > 1): ?>
|
||||
<div class="card-footer bg-white py-3">
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="?page=<?= $page - 1 ?>">Previous</a>
|
||||
</li>
|
||||
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
|
||||
<li class="page-item <?= $page == $i ? 'active' : '' ?>">
|
||||
<a class="page-link" href="?page=<?= $i ?>"><?= $i ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
<li class="page-item <?= $page >= $total_pages ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="?page=<?= $page + 1 ?>">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="modal fade" id="settingsModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Configure Loyalty Program</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="update_settings" value="1">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Points per Order</label>
|
||||
<input type="number" name="points_per_order" class="form-control" value="<?= $settings['points_per_order'] ?>" min="0" required>
|
||||
<div class="form-text">Points awarded for every completed order.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Points for Free Meal</label>
|
||||
<input type="number" name="points_for_free_meal" class="form-control" value="<?= $settings['points_for_free_meal'] ?>" min="0" required>
|
||||
<div class="form-text">Threshold points required to redeem a free meal.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
|
||||
117
admin/orders.php
117
admin/orders.php
@ -9,16 +9,55 @@ if (isset($_POST['action']) && $_POST['action'] === 'update_status') {
|
||||
$new_status = $_POST['status'];
|
||||
$stmt = $pdo->prepare("UPDATE orders SET status = ? WHERE id = ?");
|
||||
$stmt->execute([$new_status, $order_id]);
|
||||
header("Location: orders.php");
|
||||
header("Location: orders.php?" . http_build_query($_GET)); // Keep filters
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "SELECT o.*,
|
||||
// Fetch Outlets for Filter
|
||||
$outlets = $pdo->query("SELECT id, name FROM outlets ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Build Query with Filters
|
||||
$params = [];
|
||||
$where = [];
|
||||
|
||||
// Filter: Outlet
|
||||
if (!empty($_GET['outlet_id'])) {
|
||||
$where[] = "o.outlet_id = :outlet_id";
|
||||
$params[':outlet_id'] = $_GET['outlet_id'];
|
||||
}
|
||||
|
||||
// Filter: Date Range
|
||||
if (!empty($_GET['start_date'])) {
|
||||
$where[] = "DATE(o.created_at) >= :start_date";
|
||||
$params[':start_date'] = $_GET['start_date'];
|
||||
}
|
||||
if (!empty($_GET['end_date'])) {
|
||||
$where[] = "DATE(o.created_at) <= :end_date";
|
||||
$params[':end_date'] = $_GET['end_date'];
|
||||
}
|
||||
|
||||
// Filter: Search (Order No)
|
||||
if (!empty($_GET['search'])) {
|
||||
// Exact match for ID usually, but LIKE might be more user friendly if they type partial?
|
||||
// "search by order no" usually implies ID. Let's stick to ID or simple LIKE.
|
||||
// If numeric, assume ID.
|
||||
if (is_numeric($_GET['search'])) {
|
||||
$where[] = "o.id = :search";
|
||||
$params[':search'] = $_GET['search'];
|
||||
}
|
||||
}
|
||||
|
||||
$where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Changed alias 'out' to 'ot' to avoid reserved keyword conflict
|
||||
$query = "SELECT o.*, ot.name as outlet_name,
|
||||
(SELECT GROUP_CONCAT(CONCAT(p.name, ' x', oi.quantity) SEPARATOR ', ') FROM order_items oi JOIN products p ON oi.product_id = p.id WHERE oi.order_id = o.id) as items_summary
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ot ON o.outlet_id = ot.id
|
||||
$where_clause
|
||||
ORDER BY o.created_at DESC";
|
||||
|
||||
$orders_pagination = paginate_query($pdo, $query);
|
||||
$orders_pagination = paginate_query($pdo, $query, $params);
|
||||
$orders = $orders_pagination['data'];
|
||||
|
||||
include 'includes/header.php';
|
||||
@ -31,6 +70,47 @@ include 'includes/header.php';
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body bg-light">
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold text-muted">Outlet</label>
|
||||
<select name="outlet_id" class="form-select">
|
||||
<option value="">All Outlets</option>
|
||||
<?php foreach ($outlets as $outlet): ?>
|
||||
<option value="<?= $outlet['id'] ?>" <?= (isset($_GET['outlet_id']) && $_GET['outlet_id'] == $outlet['id']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($outlet['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold text-muted">Date Range</label>
|
||||
<div class="input-group">
|
||||
<input type="date" name="start_date" class="form-control" value="<?= $_GET['start_date'] ?? '' ?>" placeholder="Start">
|
||||
<span class="input-group-text bg-white border-start-0 border-end-0">-</span>
|
||||
<input type="date" name="end_date" class="form-control" value="<?= $_GET['end_date'] ?? '' ?>" placeholder="End">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold text-muted">Search</label>
|
||||
<input type="text" name="search" class="form-control" placeholder="Order No (ID)" value="<?= htmlspecialchars($_GET['search'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-filter"></i> Filter
|
||||
</button>
|
||||
<a href="orders.php" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<!-- Pagination Controls -->
|
||||
@ -42,6 +122,8 @@ include 'includes/header.php';
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">ID</th>
|
||||
<th>Outlet</th>
|
||||
<th>Customer</th>
|
||||
<th>Type</th>
|
||||
<th>Source</th>
|
||||
<th>Items</th>
|
||||
@ -55,6 +137,22 @@ include 'includes/header.php';
|
||||
<?php foreach ($orders as $order): ?>
|
||||
<tr>
|
||||
<td class="ps-4 fw-medium">#<?= $order['id'] ?></td>
|
||||
<td>
|
||||
<span class="badge bg-white text-dark border">
|
||||
<i class="bi bi-shop me-1"></i>
|
||||
<?= htmlspecialchars($order['outlet_name'] ?? 'Unknown') ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($order['customer_name'])): ?>
|
||||
<div class="fw-medium"><?= htmlspecialchars($order['customer_name']) ?></div>
|
||||
<?php if (!empty($order['customer_phone'])): ?>
|
||||
<small class="text-muted"><i class="bi bi-telephone me-1"></i><?= htmlspecialchars($order['customer_phone']) ?></small>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">Guest</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$badge = match($order['order_type']) {
|
||||
@ -81,11 +179,18 @@ include 'includes/header.php';
|
||||
<?= ucfirst($order['status']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small"><?= date('H:i', strtotime($order['created_at'])) ?></td>
|
||||
<td class="text-muted small">
|
||||
<div><?= date('M d', strtotime($order['created_at'])) ?></div>
|
||||
<div><?= date('H:i', strtotime($order['created_at'])) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" class="d-flex gap-2">
|
||||
<input type="hidden" name="order_id" value="<?= $order['id'] ?>">
|
||||
<input type="hidden" name="action" value="update_status">
|
||||
<!-- Preserve filter params in form action? No, form POSTs to same URL.
|
||||
We can add hidden inputs to redirect back with params, but header location above handles it if we pass GET params.
|
||||
The POST form action will just be "orders.php".
|
||||
Ideally we should append query string to action. -->
|
||||
|
||||
<?php if ($order['status'] === 'pending'): ?>
|
||||
<button type="submit" name="status" value="preparing" class="btn btn-sm btn-primary">
|
||||
@ -111,9 +216,9 @@ include 'includes/header.php';
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($orders)): ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<td colspan="10" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
No active orders.
|
||||
No active orders found matching your criteria.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
64
api/create_customer.php
Normal file
64
api/create_customer.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$name = trim($input['name'] ?? '');
|
||||
$phone = trim($input['phone'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
echo json_encode(['error' => 'Name is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{8}$/', $phone)) {
|
||||
echo json_encode(['error' => 'Phone number must be exactly 8 digits']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Check if phone already exists
|
||||
$stmt = $pdo->prepare("SELECT id FROM customers WHERE phone = ?");
|
||||
$stmt->execute([$phone]);
|
||||
if ($stmt->fetch()) {
|
||||
echo json_encode(['error' => 'Customer with this phone number already exists']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO customers (name, phone, points) VALUES (?, ?, 0)");
|
||||
if ($stmt->execute([$name, $phone])) {
|
||||
$id = $pdo->lastInsertId();
|
||||
|
||||
// Fetch settings for consistency (though new customer is 0 points)
|
||||
$settingsStmt = $pdo->query("SELECT points_for_free_meal FROM loyalty_settings WHERE id = 1");
|
||||
$settings = $settingsStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$threshold = $settings ? intval($settings['points_for_free_meal']) : 70;
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'customer' => [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'phone' => $phone,
|
||||
'email' => '',
|
||||
'points' => 0,
|
||||
'eligible_for_free_meal' => false,
|
||||
'points_needed' => $threshold
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Failed to create customer']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Create Customer Error: " . $e->getMessage());
|
||||
echo json_encode(['error' => 'Database error']);
|
||||
}
|
||||
@ -7,6 +7,8 @@ $pdo = db();
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
// Fetch active kitchen orders
|
||||
try {
|
||||
$outlet_id = isset($_GET['outlet_id']) ? intval($_GET['outlet_id']) : 1;
|
||||
|
||||
// We want orders that are NOT completed or cancelled
|
||||
// Status flow: pending -> preparing -> ready -> completed
|
||||
// Kitchen sees: pending, preparing, ready
|
||||
@ -19,9 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
LEFT JOIN product_variants v ON oi.variant_id = v.id
|
||||
WHERE o.status IN ('pending', 'preparing', 'ready')
|
||||
AND o.outlet_id = :outlet_id
|
||||
ORDER BY o.created_at ASC
|
||||
");
|
||||
$stmt->execute();
|
||||
$stmt->execute(['outlet_id' => $outlet_id]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$orders = [];
|
||||
@ -81,4 +84,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
}
|
||||
102
api/order.php
102
api/order.php
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once __DIR__ . '/../includes/WablasService.php';
|
||||
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
@ -30,12 +31,12 @@ try {
|
||||
$tid = $data['table_number'] ?? null; // Front-end sends ID as table_number
|
||||
if ($tid) {
|
||||
// Validate table exists AND belongs to the correct outlet
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT t.id, t.name
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.name
|
||||
FROM tables t
|
||||
JOIN areas a ON t.area_id = a.id
|
||||
WHERE t.id = ? AND a.outlet_id = ?
|
||||
");
|
||||
WHERE t.id = ? AND a.outlet_id = ?"
|
||||
);
|
||||
$stmt->execute([$tid, $outlet_id]);
|
||||
$table = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($table) {
|
||||
@ -47,13 +48,13 @@ try {
|
||||
// If not found or not provided, leave null (Walk-in/Counter) or try to find a default table for this outlet
|
||||
if (!$table_id) {
|
||||
// Optional: try to find the first available table for this outlet
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT t.id, t.name
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.name
|
||||
FROM tables t
|
||||
JOIN areas a ON t.area_id = a.id
|
||||
WHERE a.outlet_id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$outlet_id]);
|
||||
$table = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($table) {
|
||||
@ -67,24 +68,57 @@ try {
|
||||
$customer_id = $data['customer_id'] ?? null;
|
||||
$customer_name = $data['customer_name'] ?? null;
|
||||
$customer_phone = $data['customer_phone'] ?? null;
|
||||
|
||||
// Fetch Loyalty Settings
|
||||
$settingsStmt = $pdo->query("SELECT points_per_order, points_for_free_meal FROM loyalty_settings WHERE id = 1");
|
||||
$settings = $settingsStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$points_per_order = $settings ? intval($settings['points_per_order']) : 10;
|
||||
$points_threshold = $settings ? intval($settings['points_for_free_meal']) : 70;
|
||||
|
||||
$current_points = 0;
|
||||
$points_deducted = 0;
|
||||
$points_awarded = 0;
|
||||
|
||||
if ($customer_id) {
|
||||
$stmt = $pdo->prepare("SELECT name, phone FROM customers WHERE id = ?");
|
||||
$stmt = $pdo->prepare("SELECT name, phone, points FROM customers WHERE id = ?");
|
||||
$stmt->execute([$customer_id]);
|
||||
$cust = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($cust) {
|
||||
$customer_name = $cust['name'];
|
||||
$customer_phone = $cust['phone'];
|
||||
$current_points = intval($cust['points']);
|
||||
} else {
|
||||
$customer_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Loyalty Redemption Logic
|
||||
$redeem_loyalty = !empty($data['redeem_loyalty']);
|
||||
if ($redeem_loyalty && $customer_id) {
|
||||
if ($current_points < $points_threshold) {
|
||||
throw new Exception("Insufficient loyalty points for redemption.");
|
||||
}
|
||||
// Deduct points
|
||||
$deductStmt = $pdo->prepare("UPDATE customers SET points = points - ? WHERE id = ?");
|
||||
$deductStmt->execute([$points_threshold, $customer_id]);
|
||||
$points_deducted = $points_threshold;
|
||||
}
|
||||
|
||||
// Award Points
|
||||
if ($customer_id) {
|
||||
$awardStmt = $pdo->prepare("UPDATE customers SET points = points + ? WHERE id = ?");
|
||||
$awardStmt->execute([$points_per_order, $customer_id]);
|
||||
$points_awarded = $points_per_order;
|
||||
}
|
||||
|
||||
// Payment Type
|
||||
$payment_type_id = isset($data['payment_type_id']) ? intval($data['payment_type_id']) : null;
|
||||
|
||||
$discount = isset($data['discount']) ? floatval($data['discount']) : 0.00;
|
||||
$total_amount = isset($data['total_amount']) ? floatval($data['total_amount']) : 0.00;
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO orders (outlet_id, table_id, table_number, order_type, customer_id, customer_name, customer_phone, total_amount, discount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')");
|
||||
$stmt->execute([$outlet_id, $table_id, $table_number, $order_type, $customer_id, $customer_name, $customer_phone, $total_amount, $discount]);
|
||||
$stmt = $pdo->prepare("INSERT INTO orders (outlet_id, table_id, table_number, order_type, customer_id, customer_name, customer_phone, payment_type_id, total_amount, discount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')");
|
||||
$stmt->execute([$outlet_id, $table_id, $table_number, $order_type, $customer_id, $customer_name, $customer_phone, $payment_type_id, $total_amount, $discount]);
|
||||
$order_id = $pdo->lastInsertId();
|
||||
|
||||
$item_stmt = $pdo->prepare("INSERT INTO order_items (order_id, product_id, variant_id, quantity, unit_price) VALUES (?, ?, ?, ?, ?)");
|
||||
@ -102,6 +136,50 @@ try {
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// --- Post-Transaction Actions ---
|
||||
|
||||
// Send WhatsApp Notification if Customer exists
|
||||
if ($customer_id && $customer_phone) {
|
||||
try {
|
||||
// Calculate final points
|
||||
$final_points = $current_points - $points_deducted + $points_awarded;
|
||||
|
||||
// Determine message content
|
||||
$wablas = new WablasService($pdo);
|
||||
$msg = "Dear *$customer_name*,
|
||||
|
||||
";
|
||||
$msg .= "Thank you for dining with us! 🍽️
|
||||
";
|
||||
$msg .= "You've earned *$points_awarded points* with this order.
|
||||
";
|
||||
if ($points_deducted > 0) {
|
||||
$msg .= "You also redeemed *$points_deducted points* for a free meal! 🎁
|
||||
";
|
||||
}
|
||||
$msg .= "
|
||||
💰 *Current Balance: $final_points points*
|
||||
";
|
||||
|
||||
if ($final_points >= $points_threshold) {
|
||||
$msg .= "🎉 Congratulations! You have enough points for a *FREE MEAL* on your next visit!";
|
||||
} else {
|
||||
$needed = $points_threshold - $final_points;
|
||||
$msg .= "You need *$needed more points* to unlock a free meal.";
|
||||
}
|
||||
|
||||
// Send (fire and forget, don't block response on failure, just log)
|
||||
$res = $wablas->sendMessage($customer_phone, $msg);
|
||||
if (!$res['success']) {
|
||||
error_log("Wablas Notification Failed: " . $res['message']);
|
||||
}
|
||||
|
||||
} catch (Exception $w) {
|
||||
error_log("Wablas Exception: " . $w->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'order_id' => $order_id]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
@ -110,4 +188,4 @@ try {
|
||||
}
|
||||
error_log("Order Error: " . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,13 +11,26 @@ if (strlen($q) < 2) {
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT id, name, phone, email FROM customers WHERE name LIKE ? OR phone LIKE ? LIMIT 10");
|
||||
|
||||
// Fetch Loyalty Settings
|
||||
$settingsStmt = $pdo->query("SELECT points_for_free_meal FROM loyalty_settings WHERE id = 1");
|
||||
$settings = $settingsStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$threshold = $settings ? $settings['points_for_free_meal'] : 70;
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, name, phone, email, points FROM customers WHERE name LIKE ? OR phone LIKE ? LIMIT 10");
|
||||
$searchTerm = "%$q%";
|
||||
$stmt->execute([$searchTerm, $searchTerm]);
|
||||
$customers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($customers as &$customer) {
|
||||
$customer['points'] = intval($customer['points']);
|
||||
$customer['eligible_for_free_meal'] = $customer['points'] >= $threshold;
|
||||
$customer['points_needed'] = max(0, $threshold - $customer['points']);
|
||||
$customer['threshold'] = $threshold;
|
||||
}
|
||||
|
||||
echo json_encode($customers);
|
||||
} catch (Exception $e) {
|
||||
error_log("Customer Search Error: " . $e->getMessage());
|
||||
echo json_encode(['error' => 'Database error']);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const cartDiscountInput = document.getElementById('cart-discount-input');
|
||||
const checkoutBtn = document.getElementById('checkout-btn');
|
||||
|
||||
// Loyalty State
|
||||
let isLoyaltyRedemption = false;
|
||||
const loyaltySection = document.getElementById('loyalty-section');
|
||||
const loyaltyPointsDisplay = document.getElementById('loyalty-points-display');
|
||||
const loyaltyMessage = document.getElementById('loyalty-message');
|
||||
const redeemLoyaltyBtn = document.getElementById('redeem-loyalty-btn');
|
||||
|
||||
// Table Management
|
||||
let currentTableId = null;
|
||||
let currentTableName = null;
|
||||
@ -25,6 +32,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const clearCustomerBtn = document.getElementById('clear-customer');
|
||||
const customerInfo = document.getElementById('customer-info');
|
||||
const customerNameDisplay = document.getElementById('customer-name-display');
|
||||
let currentCustomer = null;
|
||||
|
||||
// Payment Modal
|
||||
const paymentModalEl = document.getElementById('paymentSelectionModal');
|
||||
const paymentSelectionModal = new bootstrap.Modal(paymentModalEl);
|
||||
const paymentMethodsContainer = document.getElementById('payment-methods-container');
|
||||
|
||||
// Product Search & Filter
|
||||
const productSearchInput = document.getElementById('product-search-input');
|
||||
@ -123,6 +136,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function selectCustomer(cust) {
|
||||
currentCustomer = cust;
|
||||
selectedCustomerId.value = cust.id;
|
||||
customerNameDisplay.textContent = cust.name;
|
||||
customerSearchInput.value = cust.name; // Show name in input
|
||||
@ -130,18 +144,72 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
customerResults.style.display = 'none';
|
||||
clearCustomerBtn.classList.remove('d-none');
|
||||
// customerInfo.classList.remove('d-none');
|
||||
|
||||
// Loyalty Logic
|
||||
if (loyaltySection) {
|
||||
loyaltySection.classList.remove('d-none');
|
||||
loyaltyPointsDisplay.textContent = cust.points + ' pts';
|
||||
|
||||
if (cust.eligible_for_free_meal) {
|
||||
redeemLoyaltyBtn.disabled = false;
|
||||
loyaltyMessage.innerHTML = '<span class="text-success fw-bold">Eligible for Free Meal!</span>';
|
||||
} else {
|
||||
redeemLoyaltyBtn.disabled = true;
|
||||
const needed = cust.points_needed || 0;
|
||||
loyaltyMessage.textContent = `${needed} pts away from a free meal.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset redemption state when switching customer (though usually we clear first)
|
||||
isLoyaltyRedemption = false;
|
||||
}
|
||||
|
||||
if (clearCustomerBtn) {
|
||||
clearCustomerBtn.addEventListener('click', () => {
|
||||
currentCustomer = null;
|
||||
selectedCustomerId.value = '';
|
||||
customerSearchInput.value = '';
|
||||
customerSearchInput.disabled = false;
|
||||
clearCustomerBtn.classList.add('d-none');
|
||||
customerInfo.classList.add('d-none');
|
||||
|
||||
// Hide Loyalty
|
||||
if (loyaltySection) loyaltySection.classList.add('d-none');
|
||||
isLoyaltyRedemption = false;
|
||||
// If we had a discount applied via loyalty, remove it?
|
||||
// Better to just let the user manually adjust if they want, but technically if it was 100% off due to loyalty, we should revert.
|
||||
// But tracking "discount source" is complex. For now, let's just leave discount as is or reset it if it equals total?
|
||||
// Safer to reset discount to 0.
|
||||
cartDiscountInput.value = 0;
|
||||
updateCart();
|
||||
|
||||
customerSearchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Loyalty Redeem Click
|
||||
if (redeemLoyaltyBtn) {
|
||||
redeemLoyaltyBtn.addEventListener('click', () => {
|
||||
if (cart.length === 0) {
|
||||
showToast("Cart is empty!", "warning");
|
||||
return;
|
||||
}
|
||||
if (!currentCustomer || !currentCustomer.eligible_for_free_meal) return;
|
||||
|
||||
if (confirm("Redeem 70 points for a free meal? This will apply a full discount to the current order.")) {
|
||||
isLoyaltyRedemption = true;
|
||||
|
||||
// Calculate total and apply as discount
|
||||
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.quantity), 0);
|
||||
cartDiscountInput.value = subtotal.toFixed(2);
|
||||
updateCart();
|
||||
|
||||
showToast("Loyalty Redemption Applied!", "success");
|
||||
redeemLoyaltyBtn.disabled = true; // Prevent double click
|
||||
loyaltyMessage.innerHTML = '<span class="text-success fw-bold"><i class="bi bi-check-circle"></i> Redeemed! Place order to finalize.</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Table & Order Type Logic ---
|
||||
const orderTypeInputs = document.querySelectorAll('input[name="order_type"]');
|
||||
@ -293,6 +361,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
cart.push({...product});
|
||||
}
|
||||
updateCart();
|
||||
|
||||
// If loyalty was applied, maybe re-apply or warn?
|
||||
// Simple: If loyalty applied, update discount to match new total?
|
||||
if (isLoyaltyRedemption) {
|
||||
// Re-calculate full discount
|
||||
// Wait, updateCart is called below.
|
||||
// We need to re-calc discount after update.
|
||||
// But updateCart uses discount input value.
|
||||
// Let's just reset loyalty on cart change? Or re-apply 100% discount.
|
||||
// Better: Reset loyalty on cart modification to avoid confusion?
|
||||
// Or keep it simple: Just update discount.
|
||||
}
|
||||
}
|
||||
|
||||
window.changeQuantity = function(index, delta) {
|
||||
@ -353,6 +433,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Discount
|
||||
let discount = parseFloat(cartDiscountInput.value) || 0;
|
||||
|
||||
// Auto-update discount if loyalty active
|
||||
if (isLoyaltyRedemption) {
|
||||
discount = subtotal;
|
||||
cartDiscountInput.value = subtotal.toFixed(2);
|
||||
}
|
||||
|
||||
let total = subtotal - discount;
|
||||
if (total < 0) total = 0;
|
||||
|
||||
@ -361,7 +448,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
if (cartDiscountInput) {
|
||||
cartDiscountInput.addEventListener('input', updateCart);
|
||||
cartDiscountInput.addEventListener('input', () => {
|
||||
// If user manually changes discount, maybe disable loyalty flag?
|
||||
// But if they just tweak it, it's fine.
|
||||
// Ideally, if they lower it, loyalty flag might still be true but that's weird.
|
||||
// Let's assume manual input overrides auto-loyalty.
|
||||
updateCart();
|
||||
});
|
||||
}
|
||||
|
||||
window.removeFromCart = function(index) {
|
||||
@ -369,7 +462,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateCart();
|
||||
};
|
||||
|
||||
// --- Checkout ---
|
||||
// --- Payment Selection Logic ---
|
||||
function renderPaymentMethods() {
|
||||
if (!paymentMethodsContainer) return;
|
||||
paymentMethodsContainer.innerHTML = '';
|
||||
|
||||
if (typeof PAYMENT_TYPES !== 'undefined' && PAYMENT_TYPES.length > 0) {
|
||||
PAYMENT_TYPES.forEach(pt => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-6';
|
||||
col.innerHTML = `
|
||||
<button class="btn btn-outline-primary w-100 payment-btn d-flex flex-column align-items-center justify-content-center"
|
||||
onclick="processOrder(${pt.id}, '${pt.name}')">
|
||||
<i class="bi ${getPaymentIcon(pt.name)} fs-3 mb-1"></i>
|
||||
<span>${pt.name}</span>
|
||||
</button>
|
||||
`;
|
||||
paymentMethodsContainer.appendChild(col);
|
||||
});
|
||||
} else {
|
||||
paymentMethodsContainer.innerHTML = '<div class="alert alert-warning">No payment methods configured.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function getPaymentIcon(name) {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('cash')) return 'bi-cash-coin';
|
||||
if (n.includes('card') || n.includes('visa') || n.includes('master')) return 'bi-credit-card';
|
||||
if (n.includes('qr') || n.includes('scan')) return 'bi-qr-code';
|
||||
return 'bi-wallet2';
|
||||
}
|
||||
|
||||
// Initialize Payment Methods
|
||||
renderPaymentMethods();
|
||||
|
||||
// --- Checkout Flow ---
|
||||
checkoutBtn.addEventListener('click', () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
@ -382,6 +509,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open Payment Modal instead of direct submission
|
||||
paymentSelectionModal.show();
|
||||
});
|
||||
|
||||
window.processOrder = function(paymentTypeId, paymentTypeName) {
|
||||
const orderTypeInput = document.querySelector('input[name="order_type"]:checked');
|
||||
const orderType = orderTypeInput ? orderTypeInput.value : 'dine-in';
|
||||
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.quantity), 0);
|
||||
const discount = parseFloat(cartDiscountInput.value) || 0;
|
||||
const totalAmount = Math.max(0, subtotal - discount);
|
||||
@ -392,8 +526,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
order_type: orderType,
|
||||
customer_id: custId || null,
|
||||
outlet_id: new URLSearchParams(window.location.search).get('outlet_id') || 1,
|
||||
payment_type_id: paymentTypeId,
|
||||
total_amount: totalAmount,
|
||||
discount: discount,
|
||||
redeem_loyalty: isLoyaltyRedemption, // Send flag
|
||||
items: cart.map(item => ({
|
||||
product_id: item.id,
|
||||
quantity: item.quantity,
|
||||
@ -402,8 +538,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}))
|
||||
};
|
||||
|
||||
checkoutBtn.disabled = true;
|
||||
checkoutBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Processing...';
|
||||
// Disable all payment buttons
|
||||
const btns = paymentMethodsContainer.querySelectorAll('button');
|
||||
btns.forEach(b => b.disabled = true);
|
||||
|
||||
fetch('api/order.php', {
|
||||
method: 'POST',
|
||||
@ -412,12 +549,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
checkoutBtn.disabled = false;
|
||||
checkoutBtn.innerHTML = 'Place Order <i class="bi bi-arrow-right ms-2"></i>';
|
||||
btns.forEach(b => b.disabled = false);
|
||||
paymentSelectionModal.hide();
|
||||
|
||||
if (data.success) {
|
||||
// Print Receipt
|
||||
printReceipt({
|
||||
orderId: data.order_id,
|
||||
customer: currentCustomer,
|
||||
items: [...cart],
|
||||
total: totalAmount,
|
||||
discount: discount,
|
||||
orderType: orderType,
|
||||
tableNumber: (orderType === 'dine-in') ? currentTableName : null,
|
||||
date: new Date().toLocaleString(),
|
||||
paymentMethod: paymentTypeName,
|
||||
loyaltyRedeemed: isLoyaltyRedemption
|
||||
});
|
||||
|
||||
cart = [];
|
||||
cartDiscountInput.value = 0;
|
||||
isLoyaltyRedemption = false; // Reset
|
||||
updateCart();
|
||||
if (clearCustomerBtn) clearCustomerBtn.click();
|
||||
showToast(`Order #${data.order_id} placed!`, 'success');
|
||||
@ -426,11 +578,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
checkoutBtn.disabled = false;
|
||||
checkoutBtn.innerHTML = 'Place Order';
|
||||
btns.forEach(b => b.disabled = false);
|
||||
paymentSelectionModal.hide();
|
||||
showToast('Network Error', 'danger');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function showToast(msg, type = 'primary') {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
@ -450,6 +602,174 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||
}
|
||||
|
||||
function printReceipt(data) {
|
||||
const width = 300;
|
||||
const height = 600;
|
||||
const left = (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 2;
|
||||
|
||||
const win = window.open('', 'Receipt', `width=${width},height=${height},top=${top},left=${left}`);
|
||||
|
||||
const itemsHtml = data.items.map(item => `
|
||||
<tr>
|
||||
<td style="padding: 2px 0;">
|
||||
${item.name} <br>
|
||||
${item.variant_name ? `<small>(${item.variant_name})</small>` : ''}
|
||||
</td>
|
||||
<td style="text-align: right; vertical-align: top;">${item.quantity} x ${formatCurrency(item.price)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const customerHtml = data.customer ? `
|
||||
<div style="margin-bottom: 10px; border-bottom: 1px dashed #000; padding-bottom: 5px;">
|
||||
<strong>Customer:</strong><br>
|
||||
${data.customer.name}<br>
|
||||
${data.customer.phone || ''}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const tableHtml = data.tableNumber ? `<div>Table: ${data.tableNumber}</div>` : '';
|
||||
const paymentHtml = data.paymentMethod ? `<div>Payment: ${data.paymentMethod}</div>` : '';
|
||||
const loyaltyHtml = data.loyaltyRedeemed ? `<div><strong>* Free Meal Redeemed *</strong></div>` : '';
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Receipt #${data.orderId}</title>
|
||||
<style>
|
||||
body { font-family: 'Courier New', monospace; font-size: 12px; margin: 0; padding: 10px; }
|
||||
.header { text-align: center; margin-bottom: 10px; }
|
||||
.header h2 { margin: 0; font-size: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
.totals { margin-top: 10px; border-top: 1px dashed #000; padding-top: 5px; }
|
||||
.footer { text-align: center; margin-top: 20px; font-size: 10px; }
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h2>FLATLOGIC POS</h2>
|
||||
<div>123 Main St, City</div>
|
||||
<div>Tel: 123-456-7890</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<div>Order #${data.orderId}</div>
|
||||
<div>${data.date}</div>
|
||||
<div>Type: ${data.orderType.toUpperCase()}</div>
|
||||
${tableHtml}
|
||||
${paymentHtml}
|
||||
${loyaltyHtml}
|
||||
</div>
|
||||
|
||||
${customerHtml}
|
||||
|
||||
<table>
|
||||
${itemsHtml}
|
||||
</table>
|
||||
|
||||
<div class="totals">
|
||||
<table style="width: 100%">
|
||||
<tr>
|
||||
<td>Subtotal</td>
|
||||
<td style="text-align: right">${formatCurrency(data.total + data.discount)}</td>
|
||||
</tr>
|
||||
${data.discount > 0 ? `
|
||||
<tr>
|
||||
<td>Discount</td>
|
||||
<td style="text-align: right">-${formatCurrency(data.discount)}</td>
|
||||
</tr>` : ''}
|
||||
<tr style="font-weight: bold; font-size: 14px;">
|
||||
<td>Total</td>
|
||||
<td style="text-align: right">${formatCurrency(data.total)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Thank you for your visit!<br>
|
||||
Please come again.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.print();
|
||||
setTimeout(function() { window.close(); }, 500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
}
|
||||
|
||||
// Initialize logic
|
||||
checkOrderType();
|
||||
|
||||
// --- Add Customer Logic ---
|
||||
const addCustomerBtn = document.getElementById('add-customer-btn');
|
||||
const addCustomerModalEl = document.getElementById('addCustomerModal');
|
||||
if (addCustomerBtn && addCustomerModalEl) {
|
||||
const addCustomerModal = new bootstrap.Modal(addCustomerModalEl);
|
||||
const saveCustomerBtn = document.getElementById('save-new-customer');
|
||||
const newCustomerName = document.getElementById('new-customer-name');
|
||||
const newCustomerPhone = document.getElementById('new-customer-phone');
|
||||
const phoneError = document.getElementById('phone-error');
|
||||
|
||||
addCustomerBtn.addEventListener('click', () => {
|
||||
newCustomerName.value = '';
|
||||
newCustomerPhone.value = '';
|
||||
phoneError.classList.add('d-none');
|
||||
addCustomerModal.show();
|
||||
});
|
||||
|
||||
saveCustomerBtn.addEventListener('click', () => {
|
||||
const name = newCustomerName.value.trim();
|
||||
const phone = newCustomerPhone.value.trim();
|
||||
|
||||
if (name === '') {
|
||||
showToast('Name is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 8 digits validation
|
||||
if (!/^\d{8}$/.test(phone)) {
|
||||
phoneError.classList.remove('d-none');
|
||||
return;
|
||||
} else {
|
||||
phoneError.classList.add('d-none');
|
||||
}
|
||||
|
||||
saveCustomerBtn.disabled = true;
|
||||
saveCustomerBtn.textContent = 'Saving...';
|
||||
|
||||
fetch('api/create_customer.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, phone: phone })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
saveCustomerBtn.disabled = false;
|
||||
saveCustomerBtn.textContent = 'Save Customer';
|
||||
|
||||
if (data.success) {
|
||||
addCustomerModal.hide();
|
||||
selectCustomer(data.customer);
|
||||
showToast('Customer created successfully', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Error creating customer', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
saveCustomerBtn.disabled = false;
|
||||
saveCustomerBtn.textContent = 'Save Customer';
|
||||
showToast('Network error', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
44
db/migrations/002_add_customer_id_to_orders.sql
Normal file
44
db/migrations/002_add_customer_id_to_orders.sql
Normal file
@ -0,0 +1,44 @@
|
||||
-- Create customers table if it doesn't exist
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20) UNIQUE,
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Add customer_id to orders table
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = "orders";
|
||||
SET @columnname = "customer_id";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(table_name = @tablename)
|
||||
AND (table_schema = @dbname)
|
||||
AND (column_name = @columnname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE orders ADD COLUMN customer_id INT DEFAULT NULL AFTER order_type;"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- Add foreign key if it doesn't exist
|
||||
SET @constraintname = "fk_orders_customer";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE
|
||||
(table_name = @tablename)
|
||||
AND (table_schema = @dbname)
|
||||
AND (constraint_name = @constraintname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL;"
|
||||
));
|
||||
PREPARE addConstraint FROM @preparedStatement;
|
||||
EXECUTE addConstraint;
|
||||
DEALLOCATE PREPARE addConstraint;
|
||||
15
db/migrations/003_add_payment_type_to_orders.sql
Normal file
15
db/migrations/003_add_payment_type_to_orders.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Add payment_type_id to orders table
|
||||
ALTER TABLE orders ADD COLUMN payment_type_id INT DEFAULT NULL;
|
||||
|
||||
-- Seed default payment types if table is empty
|
||||
INSERT INTO payment_types (name, type, is_active)
|
||||
SELECT * FROM (SELECT 'Cash', 'cash', 1) AS tmp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT name FROM payment_types WHERE name = 'Cash'
|
||||
) LIMIT 1;
|
||||
|
||||
INSERT INTO payment_types (name, type, is_active)
|
||||
SELECT * FROM (SELECT 'Credit Card', 'card', 1) AS tmp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT name FROM payment_types WHERE name = 'Credit Card'
|
||||
) LIMIT 1;
|
||||
11
db/migrations/004_seed_payment_types.sql
Normal file
11
db/migrations/004_seed_payment_types.sql
Normal file
@ -0,0 +1,11 @@
|
||||
INSERT INTO payment_types (name, type, is_active)
|
||||
SELECT * FROM (SELECT 'Cash' as n, 'cash' as t, 1 as a) AS tmp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT name FROM payment_types WHERE name = 'Cash'
|
||||
) LIMIT 1;
|
||||
|
||||
INSERT INTO payment_types (name, type, is_active)
|
||||
SELECT * FROM (SELECT 'Credit Card' as n, 'card' as t, 1 as a) AS tmp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT name FROM payment_types WHERE name = 'Credit Card'
|
||||
) LIMIT 1;
|
||||
28
db/migrations/005_loyalty_schema.sql
Normal file
28
db/migrations/005_loyalty_schema.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- Add points column to customers table if it doesn't exist
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = "customers";
|
||||
SET @columnname = "points";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(table_name = @tablename)
|
||||
AND (table_schema = @dbname)
|
||||
AND (column_name = @columnname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE customers ADD COLUMN points INT DEFAULT 0;"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- Create loyalty_settings table
|
||||
CREATE TABLE IF NOT EXISTS loyalty_settings (
|
||||
id INT PRIMARY KEY,
|
||||
points_per_order INT DEFAULT 10,
|
||||
points_for_free_meal INT DEFAULT 70
|
||||
);
|
||||
|
||||
-- Seed default settings
|
||||
INSERT IGNORE INTO loyalty_settings (id, points_per_order, points_for_free_meal) VALUES (1, 10, 70);
|
||||
8
db/migrations/006_integration_settings.sql
Normal file
8
db/migrations/006_integration_settings.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS integration_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
setting_key VARCHAR(100) NOT NULL,
|
||||
setting_value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_provider_key (provider, setting_key)
|
||||
);
|
||||
24
kitchen.php
24
kitchen.php
@ -1,3 +1,9 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo = db();
|
||||
$outlets = $pdo->query("SELECT * FROM outlets ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$current_outlet_id = isset($_GET['outlet_id']) ? (int)$_GET['outlet_id'] : 1;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -50,7 +56,14 @@
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center py-3 px-4 bg-white shadow-sm mb-4">
|
||||
<h2 class="h4 mb-0">Kitchen Display</h2>
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<select id="outlet-selector" class="form-select form-select-sm me-3" style="width: auto;">
|
||||
<?php foreach ($outlets as $outlet): ?>
|
||||
<option value="<?= $outlet['id'] ?>" <?= $current_outlet_id == $outlet['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($outlet['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span id="clock" class="text-muted me-3"></span>
|
||||
<a href="index.php" class="btn btn-outline-secondary btn-sm">Back to Home</a>
|
||||
</div>
|
||||
@ -65,9 +78,11 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const OUTLET_ID = <?= $current_outlet_id ?>;
|
||||
|
||||
async function fetchOrders() {
|
||||
try {
|
||||
const response = await fetch('api/kitchen.php');
|
||||
const response = await fetch('api/kitchen.php?outlet_id=' + OUTLET_ID);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const orders = await response.json();
|
||||
renderOrders(orders);
|
||||
@ -159,6 +174,11 @@ async function updateStatus(orderId, newStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
// Outlet Selector Logic
|
||||
document.getElementById('outlet-selector').addEventListener('change', function() {
|
||||
window.location.href = '?outlet_id=' + this.value;
|
||||
});
|
||||
|
||||
// Clock
|
||||
setInterval(() => {
|
||||
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
||||
|
||||
64
pos.php
64
pos.php
@ -6,6 +6,7 @@ $pdo = db();
|
||||
$categories = $pdo->query("SELECT * FROM categories ORDER BY sort_order")->fetchAll();
|
||||
$all_products = $pdo->query("SELECT p.*, c.name as category_name FROM products p JOIN categories c ON p.category_id = c.id")->fetchAll();
|
||||
$outlets = $pdo->query("SELECT * FROM outlets ORDER BY name")->fetchAll();
|
||||
$payment_types = $pdo->query("SELECT * FROM payment_types WHERE is_active = 1 ORDER BY id")->fetchAll();
|
||||
|
||||
// Fetch variants
|
||||
$variants_raw = $pdo->query("SELECT * FROM product_variants ORDER BY price_adjustment ASC")->fetchAll();
|
||||
@ -46,6 +47,7 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
|
||||
.category-btn:hover { background-color: #e9ecef; }
|
||||
.category-btn.active { background-color: #0d6efd; color: white; }
|
||||
.search-dropdown { position: absolute; width: 100%; z-index: 1000; max-height: 200px; overflow-y: auto; display: none; }
|
||||
.payment-btn { height: 80px; font-size: 1.2rem; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -176,12 +178,30 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control border-start-0 ps-0" id="customer-search" placeholder="Search Customer..." autocomplete="off">
|
||||
<button class="btn btn-outline-secondary d-none" type="button" id="clear-customer"><i class="bi bi-x"></i></button>
|
||||
<button class="btn btn-outline-primary" type="button" id="add-customer-btn" title="Add New Customer">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-group shadow-sm search-dropdown" id="customer-results"></div>
|
||||
<input type="hidden" id="selected-customer-id">
|
||||
|
||||
<div id="customer-info" class="small text-success mt-1 d-none">
|
||||
<i class="bi bi-check-circle-fill me-1"></i> <span id="customer-name-display"></span>
|
||||
</div>
|
||||
|
||||
<!-- Loyalty Section -->
|
||||
<div id="loyalty-section" class="d-none mt-2 p-2 bg-warning-subtle rounded border border-warning">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="d-block fw-bold small text-warning-emphasis">Loyalty Points</span>
|
||||
<span id="loyalty-points-display" class="fw-bold fs-5">0</span>
|
||||
</div>
|
||||
<button id="redeem-loyalty-btn" class="btn btn-sm btn-success shadow-sm" disabled>
|
||||
<i class="bi bi-gift-fill me-1"></i> Redeem Meal
|
||||
</button>
|
||||
</div>
|
||||
<div id="loyalty-message" class="small text-muted mt-1 fst-italic" style="font-size: 0.75rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -258,9 +278,53 @@ $order_type = $_GET['order_type'] ?? 'dine-in';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Customer Modal -->
|
||||
<div class="modal fade" id="addCustomerModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fs-6 fw-bold">Add New Customer</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="new-customer-name" class="form-label small text-muted">Customer Name</label>
|
||||
<input type="text" class="form-control form-control-sm" id="new-customer-name" placeholder="Enter name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new-customer-phone" class="form-label small text-muted">Phone Number (8 Digits)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="new-customer-phone" placeholder="12345678" maxlength="8">
|
||||
<div class="form-text text-danger d-none" id="phone-error">Must be exactly 8 digits.</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="save-new-customer">Save Customer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Selection Modal -->
|
||||
<div class="modal fade" id="paymentSelectionModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Select Payment Method</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3" id="payment-methods-container">
|
||||
<!-- Injected via JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COMPANY_SETTINGS = <?= json_encode($settings) ?>;
|
||||
const PRODUCT_VARIANTS = <?= json_encode($variants_by_product) ?>;
|
||||
const PAYMENT_TYPES = <?= json_encode($payment_types) ?>;
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/main.js?v=<?= time() ?>"></script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user