Autosave: 20260211-014633

This commit is contained in:
Flatlogic Bot 2026-02-11 01:46:33 +00:00
parent 08b5c69a33
commit ac5d28462e
27 changed files with 2535 additions and 172 deletions

92
convert_to_invoice.php Normal file
View 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
View 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
View 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
View 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
View 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'; ?>

View File

@ -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
View 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
View 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;

View 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
View 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
View 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']);
}
?>

313
index.php
View File

@ -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>
<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
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$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';
?>
<?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) ?>" />
<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; ?>
<?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>
</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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
<?php
session_start();
session_destroy();
header("Location: index.php");
exit;

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'; ?>