331 lines
16 KiB
PHP
331 lines
16 KiB
PHP
<?php
|
|
$page_title = isset($_GET['id']) ? "Edit Quotation" : "New Quotation";
|
|
require_once 'includes/header.php';
|
|
|
|
// Auth check (Sales or Admin can manage quotations)
|
|
if (!in_array($_SESSION['role'], ['Admin', 'Sales'])) {
|
|
$_SESSION['error'] = "You do not have permission to manage quotations.";
|
|
header("Location: index.php");
|
|
exit;
|
|
}
|
|
|
|
$id = $_GET['id'] ?? null;
|
|
$quotation = null;
|
|
$items = [];
|
|
|
|
if ($id) {
|
|
$stmt = db()->prepare("SELECT * FROM quotations WHERE id = ? AND deleted_at IS NULL");
|
|
$stmt->execute([$id]);
|
|
$quotation = $stmt->fetch();
|
|
|
|
if ($quotation) {
|
|
$stmt = db()->prepare("SELECT * FROM quotation_items WHERE quotation_id = ?");
|
|
$stmt->execute([$id]);
|
|
$items = $stmt->fetchAll();
|
|
} else {
|
|
header("Location: quotations.php");
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Fetch customers and products for dropdowns
|
|
$customers = db()->query("SELECT id, name FROM customers WHERE deleted_at IS NULL ORDER BY name ASC")->fetchAll();
|
|
$products = db()->query("SELECT id, name, price FROM products WHERE deleted_at IS NULL ORDER BY name ASC")->fetchAll();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
if (!validate_csrf($_POST['csrf_token'] ?? '')) {
|
|
die("CSRF token validation failed");
|
|
}
|
|
|
|
$customer_id = $_POST['customer_id'];
|
|
$issue_date = $_POST['issue_date'];
|
|
$expiry_date = $_POST['expiry_date'];
|
|
$status = $_POST['status'];
|
|
$notes = $_POST['notes'];
|
|
$subtotal = 0;
|
|
|
|
$form_items = $_POST['items'] ?? [];
|
|
$processed_items = [];
|
|
|
|
foreach ($form_items as $item) {
|
|
if (!empty($item['product_id']) && $item['quantity'] > 0) {
|
|
$total_price = $item['quantity'] * $item['unit_price'];
|
|
$subtotal += $total_price;
|
|
$processed_items[] = [
|
|
'product_id' => $item['product_id'],
|
|
'quantity' => $item['quantity'],
|
|
'unit_price' => $item['unit_price'],
|
|
'total_price' => $total_price
|
|
];
|
|
}
|
|
}
|
|
|
|
$tax_rate = 0.10; // 10% tax example
|
|
$tax_amount = $subtotal * $tax_rate;
|
|
$total_amount = $subtotal + $tax_amount;
|
|
|
|
try {
|
|
db()->beginTransaction();
|
|
|
|
if ($id) {
|
|
$stmt = db()->prepare("UPDATE quotations SET customer_id = ?, issue_date = ?, expiry_date = ?, status = ?, subtotal = ?, tax_amount = ?, total_amount = ?, notes = ? WHERE id = ?");
|
|
$stmt->execute([$customer_id, $issue_date, $expiry_date, $status, $subtotal, $tax_amount, $total_amount, $notes, $id]);
|
|
|
|
// Delete old items
|
|
db()->prepare("DELETE FROM quotation_items WHERE quotation_id = ?")->execute([$id]);
|
|
} else {
|
|
// Generate quotation number
|
|
$q_number = 'QTN-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(2)));
|
|
|
|
$stmt = db()->prepare("INSERT INTO quotations (quotation_number, customer_id, user_id, issue_date, expiry_date, status, subtotal, tax_amount, total_amount, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
$stmt->execute([$q_number, $customer_id, $_SESSION['user_id'], $issue_date, $expiry_date, $status, $subtotal, $tax_amount, $total_amount, $notes]);
|
|
$id = db()->lastInsertId();
|
|
}
|
|
|
|
// Insert items
|
|
$item_stmt = db()->prepare("INSERT INTO quotation_items (quotation_id, product_id, quantity, unit_price, total_price) VALUES (?, ?, ?, ?, ?)");
|
|
foreach ($processed_items as $p_item) {
|
|
$item_stmt->execute([$id, $p_item['product_id'], $p_item['quantity'], $p_item['unit_price'], $p_item['total_price']]);
|
|
}
|
|
|
|
// Log action
|
|
$log_stmt = db()->prepare("INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details) VALUES (?, ?, ?, ?, ?)");
|
|
$log_stmt->execute([$_SESSION['user_id'], $id ? 'UPDATE' : 'CREATE', 'QUOTATION', $id, "Number: " . ($quotation['quotation_number'] ?? $q_number)]);
|
|
|
|
db()->commit();
|
|
$_SESSION['success'] = "Quotation saved successfully.";
|
|
header("Location: quotations.php");
|
|
exit;
|
|
} catch (Exception $e) {
|
|
db()->rollBack();
|
|
$error = "Error saving quotation: " . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
$default_issue_date = $quotation['issue_date'] ?? date('Y-m-d');
|
|
$default_expiry_date = $quotation['expiry_date'] ?? date('Y-m-d', strtotime('+30 days'));
|
|
?>
|
|
|
|
<div class="mb-4">
|
|
<a href="quotations.php" class="text-decoration-none text-muted">
|
|
<i class="bi bi-arrow-left me-1"></i> Back to Quotations
|
|
</a>
|
|
<h2 class="h3 mt-2 fw-bold"><?= $page_title ?></h2>
|
|
</div>
|
|
|
|
<?php if (isset($error)): ?>
|
|
<div class="alert alert-danger"><?= e($error) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<form method="POST" id="quotation-form">
|
|
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
|
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-transparent py-3">
|
|
<h5 class="card-title mb-0">General Information</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-12">
|
|
<label class="form-label">Customer</label>
|
|
<select name="customer_id" class="form-select" required>
|
|
<option value="">Select Customer</option>
|
|
<?php foreach ($customers as $c): ?>
|
|
<option value="<?= $c['id'] ?>" <?= ($quotation['customer_id'] ?? '') == $c['id'] ? 'selected' : '' ?>>
|
|
<?= e($c['name']) ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Issue Date</label>
|
|
<input type="date" name="issue_date" class="form-control" value="<?= e($default_issue_date) ?>" required>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Expiry Date</label>
|
|
<input type="date" name="expiry_date" class="form-control" value="<?= e($default_expiry_date) ?>" required>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Status</label>
|
|
<select name="status" class="form-select" required>
|
|
<option value="Draft" <?= ($quotation['status'] ?? '') == 'Draft' ? 'selected' : '' ?>>Draft</option>
|
|
<option value="Sent" <?= ($quotation['status'] ?? '') == 'Sent' ? 'selected' : '' ?>>Sent</option>
|
|
<option value="Approved" <?= ($quotation['status'] ?? '') == 'Approved' ? 'selected' : '' ?>>Approved</option>
|
|
<option value="Rejected" <?= ($quotation['status'] ?? '') == 'Rejected' ? 'selected' : '' ?>>Rejected</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-header bg-transparent py-3 d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0">Items</h5>
|
|
<button type="button" class="btn btn-sm btn-outline-primary" id="add-item">
|
|
<i class="bi bi-plus-lg me-1"></i> Add Item
|
|
</button>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0" id="items-table">
|
|
<thead class="bg-light">
|
|
<tr>
|
|
<th class="ps-4" style="width: 40%;">Product/Service</th>
|
|
<th style="width: 15%;">Qty</th>
|
|
<th style="width: 20%;">Unit Price</th>
|
|
<th style="width: 20%;">Total</th>
|
|
<th style="width: 5%;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php
|
|
$items_to_render = !empty($items) ? $items : [['product_id' => '', 'quantity' => 1, 'unit_price' => 0]];
|
|
foreach ($items_to_render as $idx => $item):
|
|
?>
|
|
<tr class="item-row">
|
|
<td class="ps-4">
|
|
<select name="items[<?= $idx ?>][product_id]" class="form-select product-select" required>
|
|
<option value="" data-price="0">Select Product</option>
|
|
<?php foreach ($products as $p): ?>
|
|
<option value="<?= $p['id'] ?>" data-price="<?= $p['price'] ?>" <?= $item['product_id'] == $p['id'] ? 'selected' : '' ?>>
|
|
<?= e($p['name']) ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input type="number" name="items[<?= $idx ?>][quantity]" class="form-control qty-input" value="<?= $item['quantity'] ?>" step="0.01" min="0.01" required>
|
|
</td>
|
|
<td>
|
|
<div class="input-group">
|
|
<span class="input-group-text bg-light">$</span>
|
|
<input type="number" name="items[<?= $idx ?>][unit_price]" class="form-control price-input" value="<?= $item['unit_price'] ?>" step="0.01" min="0" required>
|
|
</div>
|
|
</td>
|
|
<td class="text-end fw-medium row-total">
|
|
<?= format_currency(($item['quantity'] * $item['unit_price'])) ?>
|
|
</td>
|
|
<td class="pe-4">
|
|
<button type="button" class="btn btn-link text-danger p-0 remove-item">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-transparent py-3">
|
|
<h5 class="card-title mb-0">Notes</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<textarea name="notes" class="form-control" rows="4" placeholder="Additional information..."><?= e($quotation['notes'] ?? '') ?></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Subtotal</span>
|
|
<span class="fw-medium" id="subtotal"><?= format_currency($quotation['subtotal'] ?? 0) ?></span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Tax (10%)</span>
|
|
<span class="fw-medium" id="tax-amount"><?= format_currency($quotation['tax_amount'] ?? 0) ?></span>
|
|
</div>
|
|
<hr>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span class="h6 mb-0 fw-bold">Total</span>
|
|
<span class="h5 mb-0 fw-bold text-primary" id="total-amount"><?= format_currency($quotation['total_amount'] ?? 0) ?></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary w-100 py-3">
|
|
<i class="bi bi-check2-circle me-2"></i> Save Quotation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const table = document.getElementById('items-table').getElementsByTagName('tbody')[0];
|
|
const addButton = document.getElementById('add-item');
|
|
let rowIdx = <?= count($items_to_render) ?>;
|
|
|
|
function formatCurrency(amount) {
|
|
return '$' + parseFloat(amount).toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
|
|
}
|
|
|
|
function calculateTotals() {
|
|
let subtotal = 0;
|
|
document.querySelectorAll('.item-row').forEach(row => {
|
|
const qty = parseFloat(row.querySelector('.qty-input').value) || 0;
|
|
const price = parseFloat(row.querySelector('.price-input').value) || 0;
|
|
const total = qty * price;
|
|
subtotal += total;
|
|
row.querySelector('.row-total').textContent = formatCurrency(total);
|
|
});
|
|
|
|
const taxRate = 0.10;
|
|
const taxAmount = subtotal * taxRate;
|
|
const totalAmount = subtotal + taxAmount;
|
|
|
|
document.getElementById('subtotal').textContent = formatCurrency(subtotal);
|
|
document.getElementById('tax-amount').textContent = formatCurrency(taxAmount);
|
|
document.getElementById('total-amount').textContent = formatCurrency(totalAmount);
|
|
}
|
|
|
|
addButton.addEventListener('click', function() {
|
|
const newRow = table.rows[0].cloneNode(true);
|
|
newRow.querySelectorAll('input').forEach(input => {
|
|
input.value = input.classList.contains('qty-input') ? '1' : '0';
|
|
const name = input.getAttribute('name');
|
|
input.setAttribute('name', name.replace(/\[\d+\]/, '[' + rowIdx + ']'));
|
|
});
|
|
newRow.querySelectorAll('select').forEach(select => {
|
|
select.value = '';
|
|
const name = select.getAttribute('name');
|
|
select.setAttribute('name', name.replace(/\[\d+\]/, '[' + rowIdx + ']'));
|
|
});
|
|
newRow.querySelector('.row-total').textContent = '$0.00';
|
|
table.appendChild(newRow);
|
|
rowIdx++;
|
|
attachListeners(newRow);
|
|
});
|
|
|
|
function attachListeners(row) {
|
|
row.querySelector('.remove-item').addEventListener('click', function() {
|
|
if (table.rows.length > 1) {
|
|
row.remove();
|
|
calculateTotals();
|
|
}
|
|
});
|
|
|
|
row.querySelector('.product-select').addEventListener('change', function() {
|
|
const price = this.options[this.selectedIndex].getAttribute('data-price');
|
|
if (price) {
|
|
row.querySelector('.price-input').value = price;
|
|
calculateTotals();
|
|
}
|
|
});
|
|
|
|
row.querySelector('.qty-input').addEventListener('input', calculateTotals);
|
|
row.querySelector('.price-input').addEventListener('input', calculateTotals);
|
|
}
|
|
|
|
document.querySelectorAll('.item-row').forEach(attachListeners);
|
|
calculateTotals();
|
|
});
|
|
</script>
|
|
|
|
<?php require_once 'includes/footer.php'; ?>
|