diff --git a/convert_to_invoice.php b/convert_to_invoice.php new file mode 100644 index 0000000..3a00226 --- /dev/null +++ b/convert_to_invoice.php @@ -0,0 +1,92 @@ +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; +} \ No newline at end of file diff --git a/cron/reminder_scheduler.php b/cron/reminder_scheduler.php new file mode 100644 index 0000000..7f12878 --- /dev/null +++ b/cron/reminder_scheduler.php @@ -0,0 +1,101 @@ +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 = " +

Payment Reminder

+

Dear {$invoice['customer_name']},

+

This is a friendly reminder that your invoice #{$invoice['invoice_number']} with an amount of " . format_currency($invoice['total_amount']) . " was due on {$invoice['due_date']}.

+

Please kindly arrange for payment at your earliest convenience. We have attached the invoice for your reference.

+

Thank you for your business!

+ "; + + $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 = " +

Quotation Follow-up

+

Dear {$quote['customer_name']},

+

We're following up on the quotation #{$quote['quotation_number']} we sent you on {$quote['issue_date']}.

+

The quotation is valid until {$quote['expiry_date']}. Please let us know if you have any questions or if you're ready to proceed. We have attached the quotation for your convenience.

+

Best regards,
The Sales Team

+ "; + + $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; \ No newline at end of file diff --git a/customer_delete.php b/customer_delete.php new file mode 100644 index 0000000..b4a652b --- /dev/null +++ b/customer_delete.php @@ -0,0 +1,24 @@ +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; \ No newline at end of file diff --git a/customer_form.php b/customer_form.php new file mode 100644 index 0000000..f7b81fa --- /dev/null +++ b/customer_form.php @@ -0,0 +1,111 @@ +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'; +?> + +
+
+
+

+ Cancel +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ + diff --git a/customers.php b/customers.php new file mode 100644 index 0000000..7be4d89 --- /dev/null +++ b/customers.php @@ -0,0 +1,115 @@ +prepare("SELECT * FROM customers WHERE $where ORDER BY created_at DESC"); +$stmt->execute($params); +$customers = $stmt->fetchAll(); +?> + +
+

Customers

+ + + New Customer + + +
+ +
+
+
+
+
+
+ + +
+
+
+ +
+ +
+ Clear +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameContactCategoryStatusCreatedActions
+ + No customers found. +
+
+
+
+
+
+
+ + + + + + + + + + +
+
+
+
+ + + + diff --git a/db/config.php b/db/config.php index 6250722..f1d39ba 100644 --- a/db/config.php +++ b/db/config.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) { - $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, - ]); + 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); +} \ No newline at end of file diff --git a/db/migrate.php b/db/migrate.php new file mode 100644 index 0000000..5852270 --- /dev/null +++ b/db/migrate.php @@ -0,0 +1,42 @@ +exec($sql); + } catch (PDOException $e) { + echo "Warning: " . $e->getMessage() . PHP_EOL; + } +} + +echo "Migration finished." . PHP_EOL; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..430589d --- /dev/null +++ b/db/schema.sql @@ -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; \ No newline at end of file diff --git a/includes/DocumentService.php b/includes/DocumentService.php new file mode 100644 index 0000000..1c32e30 --- /dev/null +++ b/includes/DocumentService.php @@ -0,0 +1,170 @@ +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 = ' + + + + + +
+ Bill To:
+ ' . e($doc['customer_name']) . '
+ ' . nl2br(e($doc['customer_address'])) . '
+ Email: ' . e($doc['customer_email']) . '
+ Phone: ' . e($doc['customer_phone']) . ' +
+ ' . $type . ' Details:
+ Number: ' . e($doc[$number_key]) . '
+ Date: ' . date('M d, Y', strtotime($doc[$date_key])) . '
+ ' . $expiry_label . ': ' . date('M d, Y', strtotime($doc[$expiry_key])) . '
+ Status: ' . e($doc['status']) . ' +
+

+ + + + + + + + + + '; + + foreach ($items as $item) { + $html .= ' + + + + + + '; + } + + $html .= ' + +
Product/Service DescriptionQtyUnit PriceTotal
' . e($item['product_name']) . '' . number_format($item['quantity'], 2) . '' . format_currency($item['unit_price']) . '' . format_currency($item['total_price']) . '
+

+ + + + + +
+ Notes:
+ ' . nl2br(e($doc['notes'])) . ' +
+ + + + + + + + + + + + + +
Subtotal:' . format_currency($doc['subtotal']) . '
Tax (10%):' . format_currency($doc['tax_amount']) . '
Total:' . format_currency($doc['total_amount']) . '
+
'; + return $html; + } +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..fcddb28 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,5 @@ + + + + + diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..e36e836 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,177 @@ + + + + + + + <?= isset($page_title) ? $page_title . ' - ' : '' ?>CRM System + + + + + + + + + + + + +
+' . e($_SESSION['success']) . '
'; + unset($_SESSION['success']); +} +if (isset($_SESSION['error'])) { + echo ''; + unset($_SESSION['error']); +} +?> \ No newline at end of file diff --git a/index.php b/index.php index 7205f3d..92267d7 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,181 @@ 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(); ?> - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+ +
+
+
+
+
+ +
+
+
Total Customers
+

+
+
+
-
- - - +
+
+
+
+ +
+
+
Quotations
+

+
+
+
+
+
+
+
+
+ +
+
+
Invoices
+

+
+
+
+
+
+
+
+
+ +
+
+
Total Revenue
+

+
+
+
+
+ + +
+
+
+
+
Recent Invoices
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Invoice #CustomerDue DateAmountStatus
+ + +
No invoices found.
+
+
+ +
+
+
Quick Actions
+
+ +
+
+
+
+
+
Recent Activity
+
+
+ +
+
+
:
+ +
+

+ by +
+ +
No activity recorded yet.
+ +
+
+
+
+ + diff --git a/invoice_pdf.php b/invoice_pdf.php new file mode 100644 index 0000000..91e3072 --- /dev/null +++ b/invoice_pdf.php @@ -0,0 +1,11 @@ +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; \ No newline at end of file diff --git a/invoices.php b/invoices.php new file mode 100644 index 0000000..2213dcc --- /dev/null +++ b/invoices.php @@ -0,0 +1,168 @@ +prepare($query); +$stmt->execute($params); +$invoices = $stmt->fetchAll(); + +$msg = $_GET['msg'] ?? ''; +$error = $_GET['error'] ?? ''; +?> + +
+

Invoices

+
+ + + + + + + + + +
+
+
+
+
+ + + + +
+
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Invoice #CustomerDateDue DateStatusTotalActions
+ No invoices found. +
+
+ +
+ + + + + +
+ + + + + + + + + + + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/login.php b/login.php new file mode 100644 index 0000000..f742ac2 --- /dev/null +++ b/login.php @@ -0,0 +1,116 @@ +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."; + } +} +?> + + + + + + Login - CRM System + + + + + + + + + +
+
CRM PRO
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+

Demo Credentials:
+ admin / admin
+ sales / sales
+ finance / finance

+
+
+ + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..b818557 --- /dev/null +++ b/logout.php @@ -0,0 +1,5 @@ + 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']; @@ -232,4 +233,4 @@ class MailService $html = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); return self::sendMail($to, $subject, $html, $body, $opts); } -} +} \ No newline at end of file diff --git a/product_delete.php b/product_delete.php new file mode 100644 index 0000000..be1ee5d --- /dev/null +++ b/product_delete.php @@ -0,0 +1,24 @@ +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; diff --git a/product_form.php b/product_form.php new file mode 100644 index 0000000..17e45f5 --- /dev/null +++ b/product_form.php @@ -0,0 +1,76 @@ +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'; +?> + +
+
+
+ +

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ Cancel + +
+
+
+
+
+ + \ No newline at end of file diff --git a/products.php b/products.php new file mode 100644 index 0000000..7e5e7fc --- /dev/null +++ b/products.php @@ -0,0 +1,100 @@ +prepare("SELECT * FROM products WHERE $where ORDER BY name ASC"); +$stmt->execute($params); +$products = $stmt->fetchAll(); +?> + +
+

Products & Services

+ + + Add Product + + +
+ +
+
+
+
+
+
+ + +
+
+
+ +
+ +
+ Clear +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Product/ServicePriceCreatedActions
+ + No products found. +
+
+
+
+ + + + + + + +
+
+
+
+ + + + diff --git a/quotation_form.php b/quotation_form.php new file mode 100644 index 0000000..bde33b7 --- /dev/null +++ b/quotation_form.php @@ -0,0 +1,331 @@ +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')); +?> + +
+ + Back to Quotations + +

+
+ + +
+ + +
+ + +
+
+
+
+
General Information
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
Items
+ +
+
+
+ + + + + + + + + + + + '', 'quantity' => 1, 'unit_price' => 0]]; + foreach ($items_to_render as $idx => $item): + ?> + + + + + + + + + +
Product/ServiceQtyUnit PriceTotal
+ + + + +
+ $ + +
+
+ + + +
+
+
+
+
+ +
+
+
+
Notes
+
+
+ +
+
+ +
+
+
+ Subtotal + +
+
+ Tax (10%) + +
+
+
+ Total + +
+
+
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/quotation_pdf.php b/quotation_pdf.php new file mode 100644 index 0000000..62c1d4c --- /dev/null +++ b/quotation_pdf.php @@ -0,0 +1,11 @@ +prepare($query); +$stmt->execute($params); +$quotations = $stmt->fetchAll(); + +$msg = $_GET['msg'] ?? ''; +$error = $_GET['error'] ?? ''; +?> + +
+

Quotations

+ + New Quotation + +
+ + + + + + + + + +
+
+
+
+
+ + + + +
+
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Quotation #CustomerDateExpiryStatusTotalActions
+ No quotations found. +
+
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
+ + + +
+ +
+
+
+
+ + \ No newline at end of file diff --git a/send_document.php b/send_document.php new file mode 100644 index 0000000..a121e7b --- /dev/null +++ b/send_document.php @@ -0,0 +1,85 @@ +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']) . ",

This is a friendly reminder regarding your " . $type . " " . e($doc_number) . ".

Please find it attached again for your reference.

Thank you!

Best regards,
CRM PRO Team"; +} else { + $subject = ucfirst($type) . " " . $doc_number . " from CRM PRO"; + $body = "Dear " . e($customer['name']) . ",

Please find attached your " . $type . " " . e($doc_number) . ".

Thank you for your business!

Best regards,
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; \ No newline at end of file diff --git a/user_form.php b/user_form.php new file mode 100644 index 0000000..501aed7 --- /dev/null +++ b/user_form.php @@ -0,0 +1,119 @@ + '', + '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("
", $errors); + } +} + +$page_title = $id ? "Edit User" : "Add User"; +require_once 'includes/header.php'; +?> + +
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + > +
+ +
+ Cancel + +
+
+
+
+
+
+ + diff --git a/users.php b/users.php new file mode 100644 index 0000000..f4b042a --- /dev/null +++ b/users.php @@ -0,0 +1,86 @@ +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; +} +?> + +
+

User Management

+ + Add New User + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
Full NameUsernameRoleCreated AtActions
+
+
+ + + +
+ + + + + + + + +
+
+
+
+ +