Autosave: 20260211-014633
This commit is contained in:
parent
08b5c69a33
commit
ac5d28462e
92
convert_to_invoice.php
Normal file
92
convert_to_invoice.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
// Auth check (Finance or Admin can convert to invoice)
|
||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['role'], ['Admin', 'Finance'])) {
|
||||
$_SESSION['error'] = "You do not have permission to create invoices.";
|
||||
header("Location: quotations.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header("Location: quotations.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!validate_csrf($_POST['csrf_token'] ?? '')) {
|
||||
die("CSRF token validation failed");
|
||||
}
|
||||
|
||||
$quotation_id = $_POST['quotation_id'] ?? null;
|
||||
if (!$quotation_id) {
|
||||
die("Quotation ID is required");
|
||||
}
|
||||
|
||||
try {
|
||||
db()->beginTransaction();
|
||||
|
||||
// Fetch quotation
|
||||
$stmt = db()->prepare("SELECT * FROM quotations WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$quotation_id]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
if (!$quotation) {
|
||||
throw new Exception("Quotation not found");
|
||||
}
|
||||
|
||||
if ($quotation['status'] !== 'Approved') {
|
||||
throw new Exception("Only approved quotations can be converted to invoices");
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
$invoice_number = 'INV-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(2)));
|
||||
$due_date = date('Y-m-d', strtotime('+14 days'));
|
||||
|
||||
// Create invoice
|
||||
$stmt = db()->prepare("INSERT INTO invoices (invoice_number, quotation_id, customer_id, user_id, issue_date, due_date, status, subtotal, tax_amount, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([
|
||||
$invoice_number,
|
||||
$quotation_id,
|
||||
$quotation['customer_id'],
|
||||
$_SESSION['user_id'], // Current user (Finance/Admin) who converts it
|
||||
date('Y-m-d'),
|
||||
$due_date,
|
||||
'Unpaid',
|
||||
$quotation['subtotal'],
|
||||
$quotation['tax_amount'],
|
||||
$quotation['total_amount'],
|
||||
$quotation['notes']
|
||||
]);
|
||||
$invoice_id = db()->lastInsertId();
|
||||
|
||||
// Copy items
|
||||
$stmt = db()->prepare("SELECT * FROM quotation_items WHERE quotation_id = ?");
|
||||
$stmt->execute([$quotation_id]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
$item_stmt = db()->prepare("INSERT INTO invoice_items (invoice_id, product_id, quantity, unit_price, total_price) VALUES (?, ?, ?, ?, ?)");
|
||||
foreach ($items as $item) {
|
||||
$item_stmt->execute([
|
||||
$invoice_id,
|
||||
$item['product_id'],
|
||||
$item['quantity'],
|
||||
$item['unit_price'],
|
||||
$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'], 'CREATE', 'INVOICE', $invoice_id, "Number: $invoice_number (from QTN ID: $quotation_id)"]);
|
||||
|
||||
db()->commit();
|
||||
$_SESSION['success'] = "Quotation converted to invoice successfully.";
|
||||
header("Location: invoices.php");
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
db()->rollBack();
|
||||
$_SESSION['error'] = "Error converting to invoice: " . $e->getMessage();
|
||||
header("Location: quotations.php");
|
||||
exit;
|
||||
}
|
||||
101
cron/reminder_scheduler.php
Normal file
101
cron/reminder_scheduler.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Reminder Scheduler Cron Script
|
||||
* This script should be run via CLI/Cron periodically (e.g., once daily).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once __DIR__ . '/../mail/MailService.php';
|
||||
require_once __DIR__ . '/../includes/DocumentService.php';
|
||||
|
||||
echo "Starting Reminder Scheduler..." . PHP_EOL;
|
||||
|
||||
// 1. Invoice Payment Reminders
|
||||
$overdue_invoices = db()->query("
|
||||
SELECT i.*, c.name as customer_name, c.email as customer_email
|
||||
FROM invoices i
|
||||
JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.status IN ('Unpaid', 'Partial', 'Overdue')
|
||||
AND i.due_date < CURDATE()
|
||||
AND (i.last_reminded_at IS NULL OR i.last_reminded_at < DATE_SUB(NOW(), INTERVAL 7 DAY))
|
||||
AND i.deleted_at IS NULL
|
||||
")->fetchAll();
|
||||
|
||||
foreach ($overdue_invoices as $invoice) {
|
||||
echo "Sending reminder for Invoice #{$invoice['invoice_number']} to {$invoice['customer_email']}..." . PHP_EOL;
|
||||
|
||||
$subject = "Payment Reminder: Invoice #{$invoice['invoice_number']}";
|
||||
$body = "
|
||||
<h2>Payment Reminder</h2>
|
||||
<p>Dear {$invoice['customer_name']},</p>
|
||||
<p>This is a friendly reminder that your invoice <strong>#{$invoice['invoice_number']}</strong> with an amount of <strong>" . format_currency($invoice['total_amount']) . "</strong> was due on <strong>{$invoice['due_date']}</strong>.</p>
|
||||
<p>Please kindly arrange for payment at your earliest convenience. We have attached the invoice for your reference.</p>
|
||||
<p>Thank you for your business!</p>
|
||||
";
|
||||
|
||||
$pdf_content = DocumentService::generateInvoicePDF($invoice['id'], 'S');
|
||||
$opts = [
|
||||
'attachments' => [
|
||||
[
|
||||
'data' => $pdf_content,
|
||||
'name' => "Invoice_{$invoice['invoice_number']}.pdf"
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$res = MailService::sendMail($invoice['customer_email'], $subject, $body, null, $opts);
|
||||
|
||||
if ($res['success']) {
|
||||
$stmt = db()->prepare("UPDATE invoices SET last_reminded_at = NOW(), status = 'Overdue' WHERE id = ?");
|
||||
$stmt->execute([$invoice['id']]);
|
||||
echo "Successfully reminded." . PHP_EOL;
|
||||
} else {
|
||||
echo "Failed to send: " . $res['error'] . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Quotation Follow-up Reminders
|
||||
$pending_quotations = db()->query("
|
||||
SELECT q.*, c.name as customer_name, c.email as customer_email
|
||||
FROM quotations q
|
||||
JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.status = 'Sent'
|
||||
AND q.expiry_date >= CURDATE()
|
||||
AND (q.last_reminded_at IS NULL OR q.last_reminded_at < DATE_SUB(NOW(), INTERVAL 5 DAY))
|
||||
AND q.deleted_at IS NULL
|
||||
")->fetchAll();
|
||||
|
||||
foreach ($pending_quotations as $quote) {
|
||||
echo "Sending follow-up for Quotation #{$quote['quotation_number']} to {$quote['customer_email']}..." . PHP_EOL;
|
||||
|
||||
$subject = "Follow-up: Quotation #{$quote['quotation_number']}";
|
||||
$body = "
|
||||
<h2>Quotation Follow-up</h2>
|
||||
<p>Dear {$quote['customer_name']},</p>
|
||||
<p>We're following up on the quotation <strong>#{$quote['quotation_number']}</strong> we sent you on <strong>{$quote['issue_date']}</strong>.</p>
|
||||
<p>The quotation is valid until <strong>{$quote['expiry_date']}</strong>. Please let us know if you have any questions or if you're ready to proceed. We have attached the quotation for your convenience.</p>
|
||||
<p>Best regards,<br>The Sales Team</p>
|
||||
";
|
||||
|
||||
$pdf_content = DocumentService::generateQuotationPDF($quote['id'], 'S');
|
||||
$opts = [
|
||||
'attachments' => [
|
||||
[
|
||||
'data' => $pdf_content,
|
||||
'name' => "Quotation_{$quote['quotation_number']}.pdf"
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$res = MailService::sendMail($quote['customer_email'], $subject, $body, null, $opts);
|
||||
|
||||
if ($res['success']) {
|
||||
$stmt = db()->prepare("UPDATE quotations SET last_reminded_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$quote['id']]);
|
||||
echo "Successfully sent follow-up." . PHP_EOL;
|
||||
} else {
|
||||
echo "Failed to send: " . $res['error'] . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Reminder Scheduler finished." . PHP_EOL;
|
||||
24
customer_delete.php
Normal file
24
customer_delete.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Auth and Role check
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'Admin') {
|
||||
$_SESSION['error'] = "You do not have permission to delete customers.";
|
||||
header("Location: customers.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("UPDATE customers SET deleted_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// 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'], 'DELETE', 'CUSTOMER', $id, "Soft deleted"]);
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Customer deleted successfully.";
|
||||
header("Location: customers.php");
|
||||
exit;
|
||||
111
customer_form.php
Normal file
111
customer_form.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// customer_form.php
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Auth check (Sales or Admin can manage customers)
|
||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['role'], ['Admin', 'Sales'])) {
|
||||
$_SESSION['error'] = "You do not have permission to manage customers.";
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
$customer = null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("SELECT * FROM customers WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
$customer = $stmt->fetch();
|
||||
}
|
||||
|
||||
$page_title = $id ? "Edit Customer" : "Add Customer";
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!validate_csrf($_POST['csrf_token'] ?? '')) {
|
||||
die("CSRF token validation failed");
|
||||
}
|
||||
|
||||
$name = $_POST['name'];
|
||||
$email = $_POST['email'];
|
||||
$phone = $_POST['phone'];
|
||||
$address = $_POST['address'];
|
||||
$category = $_POST['category'];
|
||||
$status = $_POST['status'];
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("UPDATE customers SET name = ?, email = ?, phone = ?, address = ?, category = ?, status = ? WHERE id = ?");
|
||||
$stmt->execute([$name, $email, $phone, $address, $category, $status, $id]);
|
||||
} else {
|
||||
$stmt = db()->prepare("INSERT INTO customers (name, email, phone, address, category, status) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$name, $email, $phone, $address, $category, $status]);
|
||||
$id = db()->lastInsertId();
|
||||
}
|
||||
|
||||
// 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', 'CUSTOMER', $id, "Name: $name"]);
|
||||
|
||||
$_SESSION['success'] = "Customer " . ($id ? "updated" : "created") . " successfully.";
|
||||
header("Location: customers.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 fw-bold mb-0"><?= $page_title ?></h1>
|
||||
<a href="customers.php" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" value="<?= e($customer['name'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control" value="<?= e($customer['email'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="text" name="phone" class="form-control" value="<?= e($customer['phone'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category</label>
|
||||
<select name="category" class="form-select">
|
||||
<option value="School" <?= ($customer['category'] ?? '') == 'School' ? 'selected' : '' ?>>School</option>
|
||||
<option value="University" <?= ($customer['category'] ?? '') == 'University' ? 'selected' : '' ?>>University</option>
|
||||
<option value="Tutoring Center" <?= ($customer['category'] ?? '') == 'Tutoring Center' ? 'selected' : '' ?>>Tutoring Center</option>
|
||||
<option value="Individual" <?= ($customer['category'] ?? '') == 'Individual' ? 'selected' : '' ?>>Individual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Address</label>
|
||||
<textarea name="address" class="form-control" rows="3"><?= e($customer['address'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="Prospect" <?= ($customer['status'] ?? '') == 'Prospect' ? 'selected' : '' ?>>Prospect</option>
|
||||
<option value="Active" <?= ($customer['status'] ?? '') == 'Active' ? 'selected' : '' ?>>Active</option>
|
||||
<option value="Inactive" <?= ($customer['status'] ?? '') == 'Inactive' ? 'selected' : '' ?>>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">Save Customer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
115
customers.php
Normal file
115
customers.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
$page_title = "Customers";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
$search = $_GET['search'] ?? '';
|
||||
$where = "deleted_at IS NULL";
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$where .= " AND (name LIKE ? OR email LIKE ? OR phone LIKE ?)";
|
||||
$params = ["%$search%", "%$search%", "%$search%"];
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("SELECT * FROM customers WHERE $where ORDER BY created_at DESC");
|
||||
$stmt->execute($params);
|
||||
$customers = $stmt->fetchAll();
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold m-0">Customers</h2>
|
||||
<?php if (in_array($user_role, ['Admin', 'Sales'])): ?>
|
||||
<a href="customer_form.php" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-2"></i>New Customer
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom bg-light">
|
||||
<form class="row g-2" method="GET">
|
||||
<div class="col-auto">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control border-start-0" placeholder="Search customers..." value="<?= e($search) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Search</button>
|
||||
</div>
|
||||
<?php if ($search): ?>
|
||||
<div class="col-auto">
|
||||
<a href="customers.php" class="btn btn-sm btn-link text-decoration-none">Clear</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">Name</th>
|
||||
<th>Contact</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($customers)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-people fs-1 d-block mb-3"></i>
|
||||
No customers found.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($customers as $c): ?>
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold"><?= e($c['name']) ?></div>
|
||||
<div class="text-muted small"><?= e($c['address']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= e($c['email']) ?></div>
|
||||
<div class="text-muted small"><?= e($c['phone']) ?></div>
|
||||
</td>
|
||||
<td><span class="text-secondary"><?= e($c['category']) ?></span></td>
|
||||
<td>
|
||||
<?php
|
||||
$badge = 'bg-secondary';
|
||||
if ($c['status'] == 'Active') $badge = 'bg-success';
|
||||
if ($c['status'] == 'Prospect') $badge = 'bg-info text-dark';
|
||||
if ($c['status'] == 'Inactive') $badge = 'bg-danger';
|
||||
?>
|
||||
<span class="badge <?= $badge ?>"><?= e($c['status']) ?></span>
|
||||
</td>
|
||||
<td class="text-muted"><?= date('M d, Y', strtotime($c['created_at'])) ?></td>
|
||||
<td class="text-end pe-4">
|
||||
<?php if (in_array($user_role, ['Admin', 'Sales'])): ?>
|
||||
<a href="customer_form.php?id=<?= $c['id'] ?>" class="btn btn-sm btn-outline-secondary py-1 px-2" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($user_role === 'Admin'): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger py-1 px-2 ms-1" title="Delete" onclick="deleteCustomer(<?= $c['id'] ?>)"><i class="bi bi-trash"></i></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deleteCustomer(id) {
|
||||
if (confirm('Are you sure you want to delete this customer?')) {
|
||||
window.location.href = 'customer_delete.php?id=' + id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
@ -5,13 +5,53 @@ define('DB_NAME', 'app_38220');
|
||||
define('DB_USER', 'app_38220');
|
||||
define('DB_PASS', '5f905595-f08d-48bc-9b00-3e1a868017ea');
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
function db() {
|
||||
static $pdo;
|
||||
if (!$pdo) {
|
||||
try {
|
||||
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', 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) {
|
||||
die("Connection failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS Protection helper
|
||||
*/
|
||||
function e($value) {
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Token generation
|
||||
*/
|
||||
function csrf_token() {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Token validation
|
||||
*/
|
||||
function validate_csrf($token) {
|
||||
return !empty($token) && hash_equals($_SESSION['csrf_token'] ?? '', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
function format_currency($amount) {
|
||||
return '$' . number_format((float)$amount, 2);
|
||||
}
|
||||
42
db/migrate.php
Normal file
42
db/migrate.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
echo "Starting Database Migration..." . PHP_EOL;
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$migrations = [
|
||||
// Add indexes if they don't exist
|
||||
"ALTER TABLE users ADD INDEX IF NOT EXISTS idx_users_username (username)",
|
||||
"ALTER TABLE users ADD INDEX IF NOT EXISTS idx_users_role (role)",
|
||||
"ALTER TABLE customers ADD INDEX IF NOT EXISTS idx_customers_email (email)",
|
||||
"ALTER TABLE customers ADD INDEX IF NOT EXISTS idx_customers_status (status)",
|
||||
"ALTER TABLE customers ADD INDEX IF NOT EXISTS idx_customers_deleted_at (deleted_at)",
|
||||
"ALTER TABLE products ADD INDEX IF NOT EXISTS idx_products_name (name)",
|
||||
"ALTER TABLE products ADD INDEX IF NOT EXISTS idx_products_deleted_at (deleted_at)",
|
||||
"ALTER TABLE quotations ADD INDEX IF NOT EXISTS idx_quotations_status (status)",
|
||||
"ALTER TABLE quotations ADD INDEX IF NOT EXISTS idx_quotations_expiry_date (expiry_date)",
|
||||
"ALTER TABLE invoices ADD INDEX IF NOT EXISTS idx_invoices_status (status)",
|
||||
"ALTER TABLE invoices ADD INDEX IF NOT EXISTS idx_invoices_due_date (due_date)",
|
||||
|
||||
// Add reminder columns if they don't exist
|
||||
"ALTER TABLE quotations ADD COLUMN IF NOT EXISTS last_reminded_at TIMESTAMP NULL DEFAULT NULL AFTER notes",
|
||||
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS last_reminded_at TIMESTAMP NULL DEFAULT NULL AFTER notes",
|
||||
|
||||
// Convert IDs to BIGINT UNSIGNED for Laravel compatibility (Note: this might fail if FKs exist, but let's try)
|
||||
// "ALTER TABLE users MODIFY id BIGINT UNSIGNED AUTO_INCREMENT",
|
||||
// This part is tricky because of foreign keys. For a "ready-to-migrate" structure,
|
||||
// it's better to ensure new tables have it.
|
||||
// For existing ones, we'll keep INT for now to avoid breaking FKs in this safe mode.
|
||||
];
|
||||
|
||||
foreach ($migrations as $sql) {
|
||||
try {
|
||||
echo "Executing: $sql" . PHP_EOL;
|
||||
$pdo->exec($sql);
|
||||
} catch (PDOException $e) {
|
||||
echo "Warning: " . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Migration finished." . PHP_EOL;
|
||||
138
db/schema.sql
Normal file
138
db/schema.sql
Normal file
@ -0,0 +1,138 @@
|
||||
-- CRM Database Schema - Migration Ready for Laravel 10
|
||||
-- Charset: utf8mb4, Engine: InnoDB
|
||||
|
||||
-- Users Table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(100) NOT NULL,
|
||||
role ENUM('Admin', 'Sales', 'Finance') NOT NULL DEFAULT 'Sales',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_users_username (username),
|
||||
INDEX idx_users_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Customers Table
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
address TEXT,
|
||||
category VARCHAR(100),
|
||||
status ENUM('Prospect', 'Active', 'Inactive') NOT NULL DEFAULT 'Prospect',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_customers_email (email),
|
||||
INDEX idx_customers_status (status),
|
||||
INDEX idx_customers_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Products Table
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
price DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_products_name (name),
|
||||
INDEX idx_products_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Quotations Table
|
||||
CREATE TABLE IF NOT EXISTS quotations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
quotation_number VARCHAR(50) NOT NULL UNIQUE,
|
||||
customer_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
issue_date DATE NOT NULL,
|
||||
expiry_date DATE NOT NULL,
|
||||
status ENUM('Draft', 'Sent', 'Approved', 'Rejected') NOT NULL DEFAULT 'Draft',
|
||||
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
total_amount DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
notes TEXT,
|
||||
last_reminded_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_quotations_customer_id (customer_id),
|
||||
INDEX idx_quotations_user_id (user_id),
|
||||
INDEX idx_quotations_status (status),
|
||||
INDEX idx_quotations_expiry_date (expiry_date),
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Quotation Items Table
|
||||
CREATE TABLE IF NOT EXISTS quotation_items (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
quotation_id BIGINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
quantity DECIMAL(10, 2) NOT NULL DEFAULT 1.00,
|
||||
unit_price DECIMAL(15, 2) NOT NULL,
|
||||
discount_amount DECIMAL(15, 2) DEFAULT 0.00,
|
||||
total_price DECIMAL(15, 2) NOT NULL,
|
||||
FOREIGN KEY (quotation_id) REFERENCES quotations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Invoices Table
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
invoice_number VARCHAR(50) NOT NULL UNIQUE,
|
||||
quotation_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
customer_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
issue_date DATE NOT NULL,
|
||||
due_date DATE NOT NULL,
|
||||
status ENUM('Unpaid', 'Partial', 'Paid', 'Overdue') NOT NULL DEFAULT 'Unpaid',
|
||||
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
total_amount DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
payment_proof VARCHAR(255) DEFAULT NULL,
|
||||
notes TEXT,
|
||||
last_reminded_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_invoices_customer_id (customer_id),
|
||||
INDEX idx_invoices_status (status),
|
||||
INDEX idx_invoices_due_date (due_date),
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (quotation_id) REFERENCES quotations(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Invoice Items Table
|
||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
invoice_id BIGINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
quantity DECIMAL(10, 2) NOT NULL DEFAULT 1.00,
|
||||
unit_price DECIMAL(15, 2) NOT NULL,
|
||||
discount_amount DECIMAL(15, 2) DEFAULT 0.00,
|
||||
total_price DECIMAL(15, 2) NOT NULL,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Audit Logs Table
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id BIGINT UNSIGNED NOT NULL,
|
||||
details TEXT,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_audit_logs_user_id (user_id),
|
||||
INDEX idx_audit_logs_entity (entity_type, entity_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
170
includes/DocumentService.php
Normal file
170
includes/DocumentService.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once '/usr/share/php/tcpdf/tcpdf.php';
|
||||
|
||||
// Extend TCPDF with custom Header and Footer
|
||||
class MYPDF extends TCPDF {
|
||||
public function Header() {
|
||||
$this->SetFont('helvetica', 'B', 20);
|
||||
$this->Cell(0, 15, 'CRM PRO DOCUMENT', 0, false, 'L', 0, '', 0, false, 'M', 'M');
|
||||
$this->Ln(10);
|
||||
$this->SetFont('helvetica', '', 10);
|
||||
$this->Cell(0, 10, 'Your Trusted Partner in Education', 0, false, 'L', 0, '', 0, false, 'M', 'M');
|
||||
$this->Line(10, 30, 200, 30);
|
||||
}
|
||||
|
||||
public function Footer() {
|
||||
$this->SetY(-15);
|
||||
$this->SetFont('helvetica', 'I', 8);
|
||||
$this->Cell(0, 10, 'Page '.$this->getAliasNumPage().'/'.$this->getAliasNbPages(), 0, false, 'C', 0, '', 0, false, 'T', 'M');
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentService {
|
||||
|
||||
public static function generateQuotationPDF($id, $dest = 'I') {
|
||||
$stmt = db()->prepare("SELECT q.*, c.name as customer_name, c.email as customer_email, c.phone as customer_phone, c.address as customer_address
|
||||
FROM quotations q
|
||||
JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = ? AND q.deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
if (!$quotation) return null;
|
||||
|
||||
$stmt = db()->prepare("SELECT qi.*, p.name as product_name
|
||||
FROM quotation_items qi
|
||||
JOIN products p ON qi.product_id = p.id
|
||||
WHERE qi.quotation_id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
$pdf = new MYPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
||||
$pdf->SetCreator(PDF_CREATOR);
|
||||
$pdf->SetAuthor('CRM PRO');
|
||||
$pdf->SetTitle('Quotation ' . $quotation['quotation_number']);
|
||||
$pdf->SetMargins(PDF_MARGIN_LEFT, 40, PDF_MARGIN_RIGHT);
|
||||
$pdf->SetHeaderMargin(PDF_MARGIN_HEADER);
|
||||
$pdf->SetFooterMargin(PDF_MARGIN_FOOTER);
|
||||
$pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM);
|
||||
$pdf->SetFont('helvetica', '', 10);
|
||||
$pdf->AddPage();
|
||||
|
||||
$html = self::buildHtml($quotation, $items, 'Quotation');
|
||||
$pdf->writeHTML($html, true, false, true, false, '');
|
||||
|
||||
return $pdf->Output('Quotation_' . $quotation['quotation_number'] . '.pdf', $dest);
|
||||
}
|
||||
|
||||
public static function generateInvoicePDF($id, $dest = 'I') {
|
||||
$stmt = db()->prepare("SELECT i.*, c.name as customer_name, c.email as customer_email, c.phone as customer_phone, c.address as customer_address
|
||||
FROM invoices i
|
||||
JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = ? AND i.deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) return null;
|
||||
|
||||
$stmt = db()->prepare("SELECT ii.*, p.name as product_name
|
||||
FROM invoice_items ii
|
||||
JOIN products p ON ii.product_id = p.id
|
||||
WHERE ii.invoice_id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
$pdf = new MYPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
||||
$pdf->SetCreator(PDF_CREATOR);
|
||||
$pdf->SetAuthor('CRM PRO');
|
||||
$pdf->SetTitle('Invoice ' . $invoice['invoice_number']);
|
||||
$pdf->SetMargins(PDF_MARGIN_LEFT, 40, PDF_MARGIN_RIGHT);
|
||||
$pdf->SetHeaderMargin(PDF_MARGIN_HEADER);
|
||||
$pdf->SetFooterMargin(PDF_MARGIN_FOOTER);
|
||||
$pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM);
|
||||
$pdf->SetFont('helvetica', '', 10);
|
||||
$pdf->AddPage();
|
||||
|
||||
$html = self::buildHtml($invoice, $items, 'Invoice');
|
||||
$pdf->writeHTML($html, true, false, true, false, '');
|
||||
|
||||
return $pdf->Output('Invoice_' . $invoice['invoice_number'] . '.pdf', $dest);
|
||||
}
|
||||
|
||||
private static function buildHtml($doc, $items, $type) {
|
||||
$number_key = ($type == 'Quotation') ? 'quotation_number' : 'invoice_number';
|
||||
$date_key = 'issue_date';
|
||||
$expiry_key = ($type == 'Quotation') ? 'expiry_date' : 'due_date';
|
||||
$expiry_label = ($type == 'Quotation') ? 'Expiry' : 'Due Date';
|
||||
|
||||
$html = '
|
||||
<table cellpadding="5">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<strong>Bill To:</strong><br>
|
||||
' . e($doc['customer_name']) . '<br>
|
||||
' . nl2br(e($doc['customer_address'])) . '<br>
|
||||
Email: ' . e($doc['customer_email']) . '<br>
|
||||
Phone: ' . e($doc['customer_phone']) . '
|
||||
</td>
|
||||
<td width="50%" align="right">
|
||||
<strong>' . $type . ' Details:</strong><br>
|
||||
Number: ' . e($doc[$number_key]) . '<br>
|
||||
Date: ' . date('M d, Y', strtotime($doc[$date_key])) . '<br>
|
||||
' . $expiry_label . ': ' . date('M d, Y', strtotime($doc[$expiry_key])) . '<br>
|
||||
Status: ' . e($doc['status']) . '
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br><br>
|
||||
<table border="1" cellpadding="5">
|
||||
<thead>
|
||||
<tr style="background-color: #f2f2f2; font-weight: bold;">
|
||||
<th width="50%">Product/Service Description</th>
|
||||
<th width="10%" align="center">Qty</th>
|
||||
<th width="20%" align="right">Unit Price</th>
|
||||
<th width="20%" align="right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$html .= '
|
||||
<tr>
|
||||
<td>' . e($item['product_name']) . '</td>
|
||||
<td align="center">' . number_format($item['quantity'], 2) . '</td>
|
||||
<td align="right">' . format_currency($item['unit_price']) . '</td>
|
||||
<td align="right">' . format_currency($item['total_price']) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
|
||||
$html .= '
|
||||
</tbody>
|
||||
</table>
|
||||
<br><br>
|
||||
<table cellpadding="5">
|
||||
<tr>
|
||||
<td width="60%">
|
||||
<strong>Notes:</strong><br>
|
||||
' . nl2br(e($doc['notes'])) . '
|
||||
</td>
|
||||
<td width="40%">
|
||||
<table cellpadding="2">
|
||||
<tr>
|
||||
<td><strong>Subtotal:</strong></td>
|
||||
<td align="right">' . format_currency($doc['subtotal']) . '</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tax (10%):</strong></td>
|
||||
<td align="right">' . format_currency($doc['tax_amount']) . '</td>
|
||||
</tr>
|
||||
<tr style="font-size: 12pt; font-weight: bold;">
|
||||
<td><strong>Total:</strong></td>
|
||||
<td align="right">' . format_currency($doc['total_amount']) . '</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>';
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
5
includes/footer.php
Normal file
5
includes/footer.php
Normal file
@ -0,0 +1,5 @@
|
||||
</div>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
177
includes/header.php
Normal file
177
includes/header.php
Normal file
@ -0,0 +1,177 @@
|
||||
<?php
|
||||
// includes/header.php
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
// Auth check
|
||||
if (!isset($_SESSION['user_id']) && basename($_SERVER['PHP_SELF']) !== 'login.php') {
|
||||
header("Location: login.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$current_page = basename($_SERVER['PHP_SELF']);
|
||||
$user_role = $_SESSION['role'] ?? 'Sales';
|
||||
$user_name = $_SESSION['full_name'] ?? 'Guest';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= isset($page_title) ? $page_title . ' - ' : '' ?>CRM System</title>
|
||||
<!-- Inter Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #111827;
|
||||
--secondary-color: #4b5563;
|
||||
--bg-light: #f9fafb;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-light);
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.navbar {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
.nav-link {
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem !important;
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--primary-color) !important;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1f2937;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
.table {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.table thead th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--secondary-color);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
border-radius: 4px;
|
||||
border-color: var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.35em 0.65em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--secondary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sidebar-link:hover, .sidebar-link.active {
|
||||
background-color: #f3f4f6;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.sidebar-link i {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg sticky-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="index.php"><i class="bi bi-layers-half me-2"></i>CRM PRO</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $current_page == 'index.php' ? 'active' : '' ?>" href="index.php">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= strpos($current_page, 'customer') !== false ? 'active' : '' ?>" href="customers.php">Customers</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= strpos($current_page, 'product') !== false ? 'active' : '' ?>" href="products.php">Products</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= strpos($current_page, 'quotation') !== false ? 'active' : '' ?>" href="quotations.php">Quotations</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= strpos($current_page, 'invoice') !== false ? 'active' : '' ?>" href="invoices.php">Invoices</a>
|
||||
</li>
|
||||
<?php if ($user_role === 'Admin'): ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= strpos($current_page, 'user') !== false ? 'active' : '' ?>" href="users.php">Users</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 text-end">
|
||||
<div class="fw-bold small"><?= e($user_name) ?></div>
|
||||
<div class="text-muted small" style="font-size: 0.7rem;"><?= e($user_role) ?></div>
|
||||
</div>
|
||||
<a href="logout.php" class="btn btn-sm btn-outline-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container-fluid py-4">
|
||||
<?php
|
||||
// Display flash messages
|
||||
if (isset($_SESSION['success'])) {
|
||||
echo '<div class="alert alert-success alert-dismissible fade show" role="alert">' . e($_SESSION['success']) . '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>';
|
||||
unset($_SESSION['success']);
|
||||
}
|
||||
if (isset($_SESSION['error'])) {
|
||||
echo '<div class="alert alert-danger alert-dismissible fade show" role="alert">' . e($_SESSION['error']) . '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>';
|
||||
unset($_SESSION['error']);
|
||||
}
|
||||
?>
|
||||
319
index.php
319
index.php
@ -1,150 +1,181 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
$page_title = "Dashboard";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
// Fetch stats using indexed columns
|
||||
$customer_count = db()->query("SELECT COUNT(*) FROM customers WHERE deleted_at IS NULL")->fetchColumn();
|
||||
$quotation_count = db()->query("SELECT COUNT(*) FROM quotations WHERE deleted_at IS NULL")->fetchColumn();
|
||||
$invoice_count = db()->query("SELECT COUNT(*) FROM invoices WHERE deleted_at IS NULL")->fetchColumn();
|
||||
$total_revenue = db()->query("SELECT SUM(total_amount) FROM invoices WHERE status = 'Paid' AND deleted_at IS NULL")->fetchColumn() ?: 0;
|
||||
|
||||
// Fetch Recent Invoices
|
||||
$recent_invoices = db()->query("
|
||||
SELECT i.*, c.name as customer_name
|
||||
FROM invoices i
|
||||
JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT 5
|
||||
")->fetchAll();
|
||||
|
||||
// Fetch Recent Activity (Audit Logs)
|
||||
$recent_activity = db()->query("
|
||||
SELECT a.*, u.full_name
|
||||
FROM audit_logs a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 10
|
||||
")->fetchAll();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card h-100 p-3 border-start border-4 border-primary">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 bg-light p-3 rounded-2 text-primary">
|
||||
<i class="bi bi-people fs-4"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0 text-muted">Total Customers</h6>
|
||||
<h4 class="mb-0 fw-bold"><?= e($customer_count) ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card h-100 p-3 border-start border-4 border-info">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 bg-light p-3 rounded-2 text-info">
|
||||
<i class="bi bi-file-earmark-text fs-4"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0 text-muted">Quotations</h6>
|
||||
<h4 class="mb-0 fw-bold"><?= e($quotation_count) ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card h-100 p-3 border-start border-4 border-warning">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 bg-light p-3 rounded-2 text-warning">
|
||||
<i class="bi bi-receipt fs-4"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0 text-muted">Invoices</h6>
|
||||
<h4 class="mb-0 fw-bold"><?= e($invoice_count) ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card h-100 p-3 border-start border-4 border-success">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 bg-light p-3 rounded-2 text-success">
|
||||
<i class="bi bi-cash-stack fs-4"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0 text-muted">Total Revenue</h6>
|
||||
<h4 class="mb-0 fw-bold"><?= format_currency($total_revenue) ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="fw-bold mb-0">Recent Invoices</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Customer</th>
|
||||
<th>Due Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent_invoices as $inv): ?>
|
||||
<tr>
|
||||
<td class="fw-medium"><?= e($inv['invoice_number']) ?></td>
|
||||
<td><?= e($inv['customer_name']) ?></td>
|
||||
<td><?= e($inv['due_date']) ?></td>
|
||||
<td><?= format_currency($inv['total_amount']) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badge_class = 'bg-secondary';
|
||||
if ($inv['status'] == 'Paid') $badge_class = 'bg-success';
|
||||
if ($inv['status'] == 'Unpaid') $badge_class = 'bg-danger';
|
||||
if ($inv['status'] == 'Overdue') $badge_class = 'bg-dark';
|
||||
?>
|
||||
<span class="badge <?= $badge_class ?>"><?= e($inv['status']) ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; if (empty($recent_invoices)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-4 text-muted">No invoices found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="fw-bold mb-0">Quick Actions</h5>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<a href="customer_form.php" class="btn btn-outline-primary w-100 py-3">
|
||||
<i class="bi bi-person-plus d-block fs-3 mb-2"></i>
|
||||
New Customer
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<div class="col-md-4">
|
||||
<a href="quotations.php" class="btn btn-outline-primary w-100 py-3">
|
||||
<i class="bi bi-file-earmark-plus d-block fs-3 mb-2"></i>
|
||||
New Quotation
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="products.php" class="btn btn-outline-primary w-100 py-3">
|
||||
<i class="bi bi-box-seam d-block fs-3 mb-2"></i>
|
||||
Add Product
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="fw-bold mb-0">Recent Activity</h5>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($recent_activity as $log): ?>
|
||||
<div class="list-group-item py-3">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1 fw-bold"><?= e($log['action']) ?>: <?= e($log['entity_type']) ?></h6>
|
||||
<small class="text-muted"><?= date('H:i', strtotime($log['created_at'])) ?></small>
|
||||
</div>
|
||||
<p class="mb-1 small"><?= e($log['details']) ?></p>
|
||||
<small class="text-muted">by <?= e($log['full_name'] ?: 'System') ?></small>
|
||||
</div>
|
||||
<?php endforeach; if (empty($recent_activity)): ?>
|
||||
<div class="p-4 text-center text-muted">No activity recorded yet.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
|
||||
11
invoice_pdf.php
Normal file
11
invoice_pdf.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/DocumentService.php';
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
die("Invoice ID is required");
|
||||
}
|
||||
|
||||
if (!DocumentService::generateInvoicePDF($id)) {
|
||||
die("Invoice not found");
|
||||
}
|
||||
35
invoice_update_status.php
Normal file
35
invoice_update_status.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
// Auth and Role check
|
||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['role'], ['Admin', 'Finance'])) {
|
||||
$_SESSION['error'] = "You do not have permission to update invoice status.";
|
||||
header("Location: invoices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header("Location: invoices.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$invoice_id = $_POST['invoice_id'] ?? null;
|
||||
$status = $_POST['status'] ?? null;
|
||||
|
||||
if ($invoice_id && $status) {
|
||||
try {
|
||||
$stmt = db()->prepare("UPDATE invoices SET status = ? WHERE id = ?");
|
||||
$stmt->execute([$status, $invoice_id]);
|
||||
|
||||
// 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'], 'UPDATE_STATUS', 'INVOICE', $invoice_id, "New status: $status"]);
|
||||
|
||||
$_SESSION['success'] = "Invoice status updated to $status.";
|
||||
} catch (Exception $e) {
|
||||
$_SESSION['error'] = "Error updating status: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
header("Location: invoices.php");
|
||||
exit;
|
||||
168
invoices.php
Normal file
168
invoices.php
Normal file
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
$page_title = "Invoices";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
$search = $_GET['search'] ?? '';
|
||||
$where = "WHERE i.deleted_at IS NULL";
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$where .= " AND (i.invoice_number LIKE ? OR c.name LIKE ?)";
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
|
||||
$query = "SELECT i.*, c.name as customer_name, c.email as customer_email
|
||||
FROM invoices i
|
||||
JOIN customers c ON i.customer_id = c.id
|
||||
$where
|
||||
ORDER BY i.created_at DESC";
|
||||
$stmt = db()->prepare($query);
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
|
||||
$msg = $_GET['msg'] ?? '';
|
||||
$error = $_GET['error'] ?? '';
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="h3 mb-0">Invoices</h2>
|
||||
</div>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= e($msg) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= e($error) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-10">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control border-start-0 ps-0"
|
||||
placeholder="Search by number or customer..." value="<?= e($search) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-light w-100">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Invoice #</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($invoices)): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
No invoices found.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($invoices as $i): ?>
|
||||
<tr>
|
||||
<td class="ps-4 fw-medium"><?= e($i['invoice_number']) ?></td>
|
||||
<td>
|
||||
<div><?= e($i['customer_name']) ?></div>
|
||||
<small class="text-muted"><?= e($i['customer_email']) ?></small>
|
||||
</td>
|
||||
<td><?= date('M d, Y', strtotime($i['issue_date'])) ?></td>
|
||||
<td><?= date('M d, Y', strtotime($i['due_date'])) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badge_class = 'bg-secondary';
|
||||
switch($i['status']) {
|
||||
case 'Paid': $badge_class = 'bg-success'; break;
|
||||
case 'Unpaid': $badge_class = 'bg-warning text-dark'; break;
|
||||
case 'Partial': $badge_class = 'bg-info text-dark'; break;
|
||||
case 'Overdue': $badge_class = 'bg-danger'; break;
|
||||
}
|
||||
?>
|
||||
<span class="badge rounded-pill <?= $badge_class ?>">
|
||||
<?= $i['status'] ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end fw-bold"><?= format_currency($i['total_amount']) ?></td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group">
|
||||
<a href="invoice_pdf.php?id=<?= $i['id'] ?>" class="btn btn-sm btn-outline-primary" title="Download PDF" target="_blank">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
<a href="send_document.php?type=invoice&id=<?= $i['id'] ?>" class="btn btn-sm btn-outline-info" title="Email to Customer" onclick="return confirm('Email this invoice to <?= e($i['customer_email']) ?>?')">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</a>
|
||||
<?php if ($i['status'] !== 'Paid'): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" title="Mark as Paid" onclick="markAsPaid(<?= $i['id'] ?>)">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</button>
|
||||
<a href="send_document.php?type=invoice&id=<?= $i['id'] ?>&reminder=1" class="btn btn-sm btn-outline-warning" title="Send Reminder" onclick="return confirm('Send payment reminder to <?= e($i['customer_email']) ?>?')">
|
||||
<i class="bi bi-bell"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function markAsPaid(id) {
|
||||
if (confirm('Mark this invoice as paid?')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = 'invoice_update_status.php';
|
||||
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'hidden';
|
||||
idInput.name = 'id';
|
||||
idInput.value = id;
|
||||
form.appendChild(idInput);
|
||||
|
||||
const statusInput = document.createElement('input');
|
||||
statusInput.type = 'hidden';
|
||||
statusInput.name = 'status';
|
||||
statusInput.value = 'Paid';
|
||||
form.appendChild(statusInput);
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
116
login.php
Normal file
116
login.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
// login.php
|
||||
require_once 'db/config.php';
|
||||
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = $_POST['username'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if ($username && $password) {
|
||||
$stmt = db()->prepare("SELECT * FROM users WHERE username = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['full_name'] = $user['full_name'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
} else {
|
||||
$error = "Invalid username or password.";
|
||||
}
|
||||
} else {
|
||||
$error = "Please enter both username and password.";
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - CRM System</title>
|
||||
<!-- Inter Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #f9fafb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: #111827;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #111827;
|
||||
border-color: #111827;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1f2937;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="brand">CRM PRO</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger py-2 small"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="login.php">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label small fw-bold">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-bold">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-2">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center text-muted small">
|
||||
<p>Demo Credentials:<br>
|
||||
admin / admin<br>
|
||||
sales / sales<br>
|
||||
finance / finance</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
5
logout.php
Normal file
5
logout.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
session_start();
|
||||
session_destroy();
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
class MailService
|
||||
{
|
||||
// Universal mail sender (no attachments by design)
|
||||
// Universal mail sender
|
||||
public static function sendMail($to, string $subject, string $htmlBody, ?string $textBody = null, array $opts = [])
|
||||
{
|
||||
$cfg = self::loadConfig();
|
||||
@ -18,26 +18,26 @@ class MailService
|
||||
if (file_exists($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'libphp-phpmailer/autoload.php';
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'libphp-phpmailer/src/Exception.php';
|
||||
@require_once 'libphp-phpmailer/src/SMTP.php';
|
||||
@require_once 'libphp-phpmailer/src/PHPMailer.php';
|
||||
}
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'PHPMailer/src/Exception.php';
|
||||
@require_once 'PHPMailer/src/SMTP.php';
|
||||
@require_once 'PHPMailer/src/PHPMailer.php';
|
||||
}
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'PHPMailer/Exception.php';
|
||||
@require_once 'PHPMailer/SMTP.php';
|
||||
@require_once 'PHPMailer/PHPMailer.php';
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
return [ 'success' => false, 'error' => 'PHPMailer not available' ];
|
||||
}
|
||||
|
||||
@ -82,6 +82,17 @@ class MailService
|
||||
foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) $mail->addCC($cc); }
|
||||
foreach ((array)($opts['bcc'] ?? []) as $bcc){ if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) $mail->addBCC($bcc); }
|
||||
|
||||
// Attachments
|
||||
if (!empty($opts['attachments'])) {
|
||||
foreach ($opts['attachments'] as $attachment) {
|
||||
if (isset($attachment['data']) && isset($attachment['name'])) {
|
||||
$mail->addStringAttachment($attachment['data'], $attachment['name']);
|
||||
} elseif (isset($attachment['path'])) {
|
||||
$mail->addAttachment($attachment['path'], $attachment['name'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional DKIM
|
||||
if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) {
|
||||
$mail->DKIM_domain = $cfg['dkim_domain'];
|
||||
@ -113,32 +124,27 @@ class MailService
|
||||
}
|
||||
|
||||
// Send a contact message
|
||||
// $to can be: a single email string, a comma-separated list, an array of emails, or null (fallback to MAIL_TO/MAIL_FROM)
|
||||
public static function sendContactMessage(string $name, string $email, string $message, $to = null, string $subject = 'New contact form')
|
||||
{
|
||||
$cfg = self::loadConfig();
|
||||
|
||||
// Try Composer autoload if available (for PHPMailer)
|
||||
$autoload = __DIR__ . '/../vendor/autoload.php';
|
||||
if (file_exists($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
// Fallback to system-wide PHPMailer (installed via apt: libphp-phpmailer)
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
// Debian/Ubuntu package layout (libphp-phpmailer)
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'libphp-phpmailer/autoload.php';
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'libphp-phpmailer/src/Exception.php';
|
||||
@require_once 'libphp-phpmailer/src/SMTP.php';
|
||||
@require_once 'libphp-phpmailer/src/PHPMailer.php';
|
||||
}
|
||||
// Alternative layout (older PHPMailer package names)
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'PHPMailer/src/Exception.php';
|
||||
@require_once 'PHPMailer/src/SMTP.php';
|
||||
@require_once 'PHPMailer/src/PHPMailer.php';
|
||||
}
|
||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
@require_once 'PHPMailer/Exception.php';
|
||||
@require_once 'PHPMailer/SMTP.php';
|
||||
@require_once 'PHPMailer/PHPMailer.php';
|
||||
@ -146,11 +152,10 @@ class MailService
|
||||
}
|
||||
|
||||
$transport = $cfg['transport'] ?? 'smtp';
|
||||
if ($transport === 'smtp' && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
||||
if ($transport === 'smtp' && class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
return self::sendViaPHPMailer($cfg, $name, $email, $message, $to, $subject);
|
||||
}
|
||||
|
||||
// Fallback: attempt native mail() — works only if MTA is configured on the VM
|
||||
return self::sendViaNativeMail($cfg, $name, $email, $message, $to, $subject);
|
||||
}
|
||||
|
||||
@ -173,7 +178,6 @@ class MailService
|
||||
$fromName = $cfg['from_name'] ?? 'App';
|
||||
$mail->setFrom($fromEmail, $fromName);
|
||||
|
||||
// Use Reply-To for the user's email to avoid spoofing From
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$mail->addReplyTo($email, $name ?: $email);
|
||||
}
|
||||
@ -181,11 +185,9 @@ class MailService
|
||||
$mail->addReplyTo($cfg['reply_to']);
|
||||
}
|
||||
|
||||
// Destination: prefer dynamic recipients ($to), fallback to MAIL_TO; no silent FROM fallback
|
||||
$toList = [];
|
||||
if ($to) {
|
||||
if (is_string($to)) {
|
||||
// allow comma-separated list
|
||||
$toList = array_map('trim', explode(',', $to));
|
||||
} elseif (is_array($to)) {
|
||||
$toList = $to;
|
||||
@ -204,7 +206,6 @@ class MailService
|
||||
return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ];
|
||||
}
|
||||
|
||||
// DKIM (optional)
|
||||
if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) {
|
||||
$mail->DKIM_domain = $cfg['dkim_domain'];
|
||||
$mail->DKIM_selector = $cfg['dkim_selector'];
|
||||
|
||||
24
product_delete.php
Normal file
24
product_delete.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Auth and Role check
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'Admin') {
|
||||
$_SESSION['error'] = "You do not have permission to delete products.";
|
||||
header("Location: products.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("UPDATE products SET deleted_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// 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'], 'DELETE', 'PRODUCT', $id, "Soft deleted"]);
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Product deleted successfully.";
|
||||
header("Location: products.php");
|
||||
exit;
|
||||
76
product_form.php
Normal file
76
product_form.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
$product = null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("SELECT * FROM products WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
$product = $stmt->fetch();
|
||||
}
|
||||
|
||||
$page_title = $id ? "Edit Product" : "Add Product";
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// CSRF Validation
|
||||
if (!validate_csrf($_POST['csrf_token'] ?? '')) {
|
||||
die("CSRF token validation failed.");
|
||||
}
|
||||
|
||||
$name = $_POST['name'] ?? '';
|
||||
$description = $_POST['description'] ?? '';
|
||||
$price = $_POST['price'] ?? 0;
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("UPDATE products SET name = ?, description = ?, price = ? WHERE id = ?");
|
||||
$stmt->execute([$name, $description, $price, $id]);
|
||||
} else {
|
||||
$stmt = db()->prepare("INSERT INTO products (name, description, price) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$name, $description, $price]);
|
||||
}
|
||||
header("Location: products.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<a href="products.php" class="btn btn-sm btn-outline-secondary me-3"><i class="bi bi-arrow-left"></i></a>
|
||||
<h2 class="fw-bold m-0"><?= e($page_title) ?></h2>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Product Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="name" class="form-control" required value="<?= e($product['name'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Description</label>
|
||||
<textarea name="description" class="form-control" rows="3"><?= e($product['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Base Price ($)</label>
|
||||
<input type="number" step="0.01" name="price" class="form-control" required value="<?= e($product['price'] ?? '0.00') ?>">
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="products.php" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary px-4">Save Product</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
100
products.php
Normal file
100
products.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
$page_title = "Products";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
$search = $_GET['search'] ?? '';
|
||||
$where = "deleted_at IS NULL";
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$where .= " AND (name LIKE ? OR description LIKE ?)";
|
||||
$params = ["%$search%", "%$search%"];
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("SELECT * FROM products WHERE $where ORDER BY name ASC");
|
||||
$stmt->execute($params);
|
||||
$products = $stmt->fetchAll();
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold m-0">Products & Services</h2>
|
||||
<?php if (in_array($user_role, ['Admin', 'Sales'])): ?>
|
||||
<a href="product_form.php" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-2"></i>Add Product
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom bg-light">
|
||||
<form class="row g-2" method="GET">
|
||||
<div class="col-auto">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control border-start-0" placeholder="Search products..." value="<?= e($search) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Search</button>
|
||||
</div>
|
||||
<?php if ($search): ?>
|
||||
<div class="col-auto">
|
||||
<a href="products.php" class="btn btn-sm btn-link text-decoration-none">Clear</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">Product/Service</th>
|
||||
<th>Price</th>
|
||||
<th>Created</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($products)): ?>
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-box-seam fs-1 d-block mb-3"></i>
|
||||
No products found.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($products as $p): ?>
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold"><?= e($p['name']) ?></div>
|
||||
<div class="text-muted small"><?= e($p['description']) ?></div>
|
||||
</td>
|
||||
<td><span class="fw-medium text-primary"><?= format_currency($p['price']) ?></span></td>
|
||||
<td class="text-muted"><?= date('M d, Y', strtotime($p['created_at'])) ?></td>
|
||||
<td class="text-end pe-4">
|
||||
<?php if (in_array($user_role, ['Admin', 'Sales'])): ?>
|
||||
<a href="product_form.php?id=<?= $p['id'] ?>" class="btn btn-sm btn-outline-secondary py-1 px-2" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($user_role === 'Admin'): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger py-1 px-2 ms-1" title="Delete" onclick="deleteProduct(<?= $p['id'] ?>)"><i class="bi bi-trash"></i></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deleteProduct(id) {
|
||||
if (confirm('Are you sure you want to delete this product?')) {
|
||||
window.location.href = 'product_delete.php?id=' + id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
331
quotation_form.php
Normal file
331
quotation_form.php
Normal file
@ -0,0 +1,331 @@
|
||||
<?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'; ?>
|
||||
11
quotation_pdf.php
Normal file
11
quotation_pdf.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/DocumentService.php';
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
die("Quotation ID is required");
|
||||
}
|
||||
|
||||
if (!DocumentService::generateQuotationPDF($id)) {
|
||||
die("Quotation not found");
|
||||
}
|
||||
149
quotations.php
Normal file
149
quotations.php
Normal file
@ -0,0 +1,149 @@
|
||||
<?php
|
||||
$page_title = "Quotations";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
$search = $_GET['search'] ?? '';
|
||||
$where = "WHERE q.deleted_at IS NULL";
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$where .= " AND (q.quotation_number LIKE ? OR c.name LIKE ?)";
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
|
||||
$query = "SELECT q.*, c.name as customer_name, c.email as customer_email
|
||||
FROM quotations q
|
||||
JOIN customers c ON q.customer_id = c.id
|
||||
$where
|
||||
ORDER BY q.created_at DESC";
|
||||
$stmt = db()->prepare($query);
|
||||
$stmt->execute($params);
|
||||
$quotations = $stmt->fetchAll();
|
||||
|
||||
$msg = $_GET['msg'] ?? '';
|
||||
$error = $_GET['error'] ?? '';
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="h3 mb-0">Quotations</h2>
|
||||
<a href="quotation_form.php" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-2"></i>New Quotation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= e($msg) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= e($error) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-10">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control border-start-0 ps-0"
|
||||
placeholder="Search by number or customer..." value="<?= e($search) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-light w-100">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Quotation #</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Expiry</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($quotations)): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
No quotations found.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($quotations as $q): ?>
|
||||
<tr>
|
||||
<td class="ps-4 fw-medium"><?= e($q['quotation_number']) ?></td>
|
||||
<td>
|
||||
<div><?= e($q['customer_name']) ?></div>
|
||||
<small class="text-muted"><?= e($q['customer_email']) ?></small>
|
||||
</td>
|
||||
<td><?= date('M d, Y', strtotime($q['issue_date'])) ?></td>
|
||||
<td><?= date('M d, Y', strtotime($q['expiry_date'])) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badge_class = 'bg-secondary';
|
||||
switch($q['status']) {
|
||||
case 'Sent': $badge_class = 'bg-info text-dark'; break;
|
||||
case 'Approved': $badge_class = 'bg-success'; break;
|
||||
case 'Rejected': $badge_class = 'bg-danger'; break;
|
||||
case 'Draft': $badge_class = 'bg-warning text-dark'; break;
|
||||
}
|
||||
?>
|
||||
<span class="badge rounded-pill <?= $badge_class ?>">
|
||||
<?= $q['status'] ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end fw-bold"><?= format_currency($q['total_amount']) ?></td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group">
|
||||
<a href="quotation_form.php?id=<?= $q['id'] ?>" class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="quotation_pdf.php?id=<?= $q['id'] ?>" class="btn btn-sm btn-outline-primary" title="Download PDF" target="_blank">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
<a href="send_document.php?type=quotation&id=<?= $q['id'] ?>" class="btn btn-sm btn-outline-info" title="Email to Customer" onclick="return confirm('Email this quotation to <?= e($q['customer_email']) ?>?')">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</a>
|
||||
<?php if ($q['status'] === 'Sent'): ?>
|
||||
<a href="send_document.php?type=quotation&id=<?= $q['id'] ?>&reminder=1" class="btn btn-sm btn-outline-warning" title="Send Follow-up" onclick="return confirm('Send follow-up reminder to <?= e($q['customer_email']) ?>?')">
|
||||
<i class="bi bi-bell"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($q['status'] === 'Approved'): ?>
|
||||
<form action="convert_to_invoice.php" method="POST" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="quotation_id" value="<?= $q['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success" title="Convert to Invoice" onclick="return confirm('Convert this quotation to an invoice?')">
|
||||
<i class="bi bi-arrow-right-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
85
send_document.php
Normal file
85
send_document.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/DocumentService.php';
|
||||
require_once __DIR__ . '/mail/MailService.php';
|
||||
|
||||
$type = $_GET['type'] ?? '';
|
||||
$id = $_GET['id'] ?? '';
|
||||
$is_reminder = isset($_GET['reminder']);
|
||||
|
||||
if (!in_array($type, ['quotation', 'invoice']) || !$id) {
|
||||
die("Invalid request");
|
||||
}
|
||||
|
||||
$db = db();
|
||||
$customer = null;
|
||||
$doc_number = '';
|
||||
$pdf_content = '';
|
||||
$doc = null;
|
||||
|
||||
if ($type === 'quotation') {
|
||||
$stmt = $db->prepare("SELECT q.*, c.name as customer_name, c.email as customer_email
|
||||
FROM quotations q
|
||||
JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$doc = $stmt->fetch();
|
||||
if ($doc) {
|
||||
$customer = ['name' => $doc['customer_name'], 'email' => $doc['customer_email']];
|
||||
$doc_number = $doc['quotation_number'];
|
||||
$pdf_content = DocumentService::generateQuotationPDF($id, 'S');
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT i.*, c.name as customer_name, c.email as customer_email
|
||||
FROM invoices i
|
||||
JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$doc = $stmt->fetch();
|
||||
if ($doc) {
|
||||
$customer = ['name' => $doc['customer_name'], 'email' => $doc['customer_email']];
|
||||
$doc_number = $doc['invoice_number'];
|
||||
$pdf_content = DocumentService::generateInvoicePDF($id, 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$customer || !$pdf_content) {
|
||||
die("Document not found");
|
||||
}
|
||||
|
||||
if (!$customer['email']) {
|
||||
header("Location: " . ($type === 'quotation' ? 'quotations.php' : 'invoices.php') . "?error=" . urlencode("Customer does not have an email address"));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($is_reminder) {
|
||||
$subject = "REMINDER: " . ucfirst($type) . " " . $doc_number . " from CRM PRO";
|
||||
$body = "Dear " . e($customer['name']) . ",<br><br>This is a friendly reminder regarding your " . $type . " " . e($doc_number) . ".<br><br>Please find it attached again for your reference.<br><br>Thank you!<br><br>Best regards,<br>CRM PRO Team";
|
||||
} else {
|
||||
$subject = ucfirst($type) . " " . $doc_number . " from CRM PRO";
|
||||
$body = "Dear " . e($customer['name']) . ",<br><br>Please find attached your " . $type . " " . e($doc_number) . ".<br><br>Thank you for your business!<br><br>Best regards,<br>CRM PRO Team";
|
||||
}
|
||||
|
||||
$opts = [
|
||||
'attachments' => [
|
||||
[
|
||||
'data' => $pdf_content,
|
||||
'name' => ucfirst($type) . "_" . $doc_number . ".pdf"
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$result = MailService::sendMail($customer['email'], $subject, $body, null, $opts);
|
||||
|
||||
$redirect = ($type === 'quotation' ? 'quotations.php' : 'invoices.php');
|
||||
if ($result['success']) {
|
||||
// Update last reminded at
|
||||
$table = ($type === 'quotation' ? 'quotations' : 'invoices');
|
||||
$stmt = $db->prepare("UPDATE $table SET last_reminded_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
header("Location: $redirect?msg=" . urlencode(($is_reminder ? "Reminder" : ucfirst($type)) . " emailed successfully to " . $customer['email']));
|
||||
} else {
|
||||
header("Location: $redirect?error=" . urlencode("Failed to send email: " . $result['error']));
|
||||
}
|
||||
exit;
|
||||
119
user_form.php
Normal file
119
user_form.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// user_form.php
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Auth and Role check
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'Admin') {
|
||||
$_SESSION['error'] = "Unauthorized access.";
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$user = [
|
||||
'username' => '',
|
||||
'full_name' => '',
|
||||
'role' => 'Sales'
|
||||
];
|
||||
|
||||
if ($id) {
|
||||
$stmt = db()->prepare("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$id]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = "User not found.";
|
||||
header("Location: users.php");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = $_POST['username'] ?? '';
|
||||
$full_name = $_POST['full_name'] ?? '';
|
||||
$role = $_POST['role'] ?? 'Sales';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
$errors = [];
|
||||
if (empty($username)) $errors[] = "Username is required.";
|
||||
if (empty($full_name)) $errors[] = "Full name is required.";
|
||||
|
||||
// Check if username already exists
|
||||
$stmt = db()->prepare("SELECT id FROM users WHERE username = ? AND id != ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$username, $id ?: 0]);
|
||||
if ($stmt->fetch()) {
|
||||
$errors[] = "Username already taken.";
|
||||
}
|
||||
|
||||
if (empty($errors)) {
|
||||
if ($id) {
|
||||
if (!empty($password)) {
|
||||
$stmt = db()->prepare("UPDATE users SET username = ?, full_name = ?, role = ?, password = ? WHERE id = ?");
|
||||
$stmt->execute([$username, $full_name, $role, password_hash($password, PASSWORD_DEFAULT), $id]);
|
||||
} else {
|
||||
$stmt = db()->prepare("UPDATE users SET username = ?, full_name = ?, role = ? WHERE id = ?");
|
||||
$stmt->execute([$username, $full_name, $role, $id]);
|
||||
}
|
||||
$_SESSION['success'] = "User updated successfully.";
|
||||
} else {
|
||||
if (empty($password)) {
|
||||
$_SESSION['error'] = "Password is required for new users.";
|
||||
} else {
|
||||
$stmt = db()->prepare("INSERT INTO users (username, full_name, role, password) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$username, $full_name, $role, password_hash($password, PASSWORD_DEFAULT)]);
|
||||
$_SESSION['success'] = "User created successfully.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['error'])) {
|
||||
header("Location: users.php");
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
$_SESSION['error'] = implode("<br>", $errors);
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = $id ? "Edit User" : "Add User";
|
||||
require_once 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="fw-bold mb-0"><?= $id ? 'Edit User' : 'Add New User' ?></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Username</label>
|
||||
<input type="text" name="username" class="form-control" value="<?= e($user['username']) ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Full Name</label>
|
||||
<input type="text" name="full_name" class="form-control" value="<?= e($user['full_name']) ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Role</label>
|
||||
<select name="role" class="form-select" required>
|
||||
<option value="Admin" <?= $user['role'] === 'Admin' ? 'selected' : '' ?>>Admin</option>
|
||||
<option value="Sales" <?= $user['role'] === 'Sales' ? 'selected' : '' ?>>Sales</option>
|
||||
<option value="Finance" <?= $user['role'] === 'Finance' ? 'selected' : '' ?>>Finance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Password <?= $id ? '(leave blank to keep current)' : '' ?></label>
|
||||
<input type="password" name="password" class="form-control" <?= $id ? '' : 'required' ?>>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between pt-3">
|
||||
<a href="users.php" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
86
users.php
Normal file
86
users.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
// users.php
|
||||
$page_title = "User Management";
|
||||
require_once 'includes/header.php';
|
||||
|
||||
// Role check
|
||||
if ($user_role !== 'Admin') {
|
||||
$_SESSION['error'] = "You do not have permission to access this page.";
|
||||
header("Location: index.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fetch users
|
||||
$users = db()->query("SELECT * FROM users WHERE deleted_at IS NULL ORDER BY role, full_name")->fetchAll();
|
||||
|
||||
// Handle deletion
|
||||
if (isset($_GET['delete'])) {
|
||||
$id = (int)$_GET['delete'];
|
||||
if ($id === (int)$_SESSION['user_id']) {
|
||||
$_SESSION['error'] = "You cannot delete your own account.";
|
||||
} else {
|
||||
$stmt = db()->prepare("UPDATE users SET deleted_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$_SESSION['success'] = "User deleted successfully.";
|
||||
}
|
||||
header("Location: users.php");
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 fw-bold mb-0">User Management</h1>
|
||||
<a href="user_form.php" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>Add New User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Full Name</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created At</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold"><?= e($u['full_name']) ?></div>
|
||||
</td>
|
||||
<td><?= e($u['username']) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$role_badge = 'bg-secondary';
|
||||
if ($u['role'] === 'Admin') $role_badge = 'bg-dark';
|
||||
if ($u['role'] === 'Sales') $role_badge = 'bg-primary';
|
||||
if ($u['role'] === 'Finance') $role_badge = 'bg-info';
|
||||
?>
|
||||
<span class="badge <?= $role_badge ?>"><?= e($u['role']) ?></span>
|
||||
</td>
|
||||
<td><?= date('M d, Y', strtotime($u['created_at'])) ?></td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<a href="user_form.php?id=<?= $u['id'] ?>" class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<?php if ($u['id'] !== (int)$_SESSION['user_id']): ?>
|
||||
<a href="users.php?delete=<?= $u['id'] ?>" class="btn btn-sm btn-outline-danger" title="Delete" onclick="return confirm('Are you sure you want to delete this user?')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once 'includes/footer.php'; ?>
|
||||
Loading…
x
Reference in New Issue
Block a user