School System
This commit is contained in:
parent
c2f8250598
commit
7dde39bcd5
89
README.md
Normal file
89
README.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# School Accounting ERP
|
||||||
|
|
||||||
|
This project is a comprehensive, enterprise-grade accounting and ERP system designed specifically for educational institutions, with a focus on the Ghanaian market. It provides a robust backend for managing all financial aspects of a school, from student billing to detailed financial reporting and auditing.
|
||||||
|
|
||||||
|
The system is built with a clear separation of concerns, ensuring that the core accounting engine remains reliable and independent, while allowing for intelligence and visualization layers to be built on top.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
* **Accounting First:** The system is built around a solid, double-entry accounting engine, ensuring all financial data is accurate and balanced.
|
||||||
|
* **Read-Only Insights:** The intelligence and dashboard layers are strictly read-only, ensuring they can never corrupt the core accounting data.
|
||||||
|
* **Auditability:** A comprehensive audit trail logs every significant financial action, providing a complete and immutable history.
|
||||||
|
* **Modularity:** The system is designed in phases, allowing for incremental development and deployment.
|
||||||
|
|
||||||
|
## Features (Phases 1-5)
|
||||||
|
|
||||||
|
The backend is functionally complete and provides the following features:
|
||||||
|
|
||||||
|
* **Chart of Accounts:** A flexible and customizable chart of accounts to categorize all financial transactions.
|
||||||
|
* **Student Management:** Manage student records and enrollment.
|
||||||
|
* **Fee Structures:** Define complex fee structures and assign them to students.
|
||||||
|
* **Invoicing:** Automatically generate and issue invoices to students.
|
||||||
|
* **Payments:** Record and track student payments.
|
||||||
|
* **Expense Management:** Manage vendors and track bills and bill payments.
|
||||||
|
* **Journal Entries:** Manually create journal entries for adjustments and other accounting tasks.
|
||||||
|
* **Financial Reporting:**
|
||||||
|
* **Trial Balance:** Ensure the books are always balanced.
|
||||||
|
* **Income Statement:** Track revenue, expenses, and profitability.
|
||||||
|
* **Balance Sheet:** Get a snapshot of the school's financial position.
|
||||||
|
* **Accounts Receivable (A/R) Aging:** Monitor outstanding invoices.
|
||||||
|
* **Accounts Payable (A/P) Aging:** Track outstanding bills.
|
||||||
|
* **Financial Controls:**
|
||||||
|
* **Accounting Periods:** Close accounting periods to prevent unauthorized changes to historical data.
|
||||||
|
* **Audit Trail:**
|
||||||
|
* **Comprehensive Logging:** Every key financial transaction is logged for full traceability.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
* **Backend:** PHP 8.x (Vanilla)
|
||||||
|
* **Database:** MariaDB / MySQL
|
||||||
|
* **Web Server:** Apache
|
||||||
|
* **Frontend:** React (rendered client-side)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── api/ # API endpoints
|
||||||
|
│ ├── reports/ # Financial report endpoints
|
||||||
|
│ └── ...
|
||||||
|
├── db/ # Database scripts
|
||||||
|
│ ├── migrations/ # Database migration files
|
||||||
|
│ └── config.php # Database connection configuration
|
||||||
|
├── includes/ # Helper functions and utilities
|
||||||
|
├── mail/ # Email sending service
|
||||||
|
├── ai/ # AI/ML integration hooks
|
||||||
|
├── dashboard/ # Frontend React application
|
||||||
|
│ ├── src/
|
||||||
|
│ └── ...
|
||||||
|
├── .htaccess # Apache configuration
|
||||||
|
├── index.php # Main application entry point (serves the dashboard)
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup and Installation
|
||||||
|
|
||||||
|
1. **Environment:** Set up a standard LAMP (Linux, Apache, MySQL, PHP) stack.
|
||||||
|
2. **Database:**
|
||||||
|
* Create a new MySQL/MariaDB database.
|
||||||
|
* Update the credentials in `db/config.php`.
|
||||||
|
3. **Migrations:** Run the database migrations located in the `db/migrations/` directory in sequential order to set up the database schema.
|
||||||
|
4. **Web Server:** Configure Apache to use the project's root directory as the `DocumentRoot`.
|
||||||
|
5. **Access:** Open the project URL in your browser. The `index.php` file will automatically load the React-based dashboard.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The core backend functionality is exposed via a series of PHP-based API endpoints in the `api/` directory. The frontend dashboard interacts with these endpoints to display data. Key endpoints include:
|
||||||
|
|
||||||
|
* `/api/students_post.php`
|
||||||
|
* `/api/invoices_post.php`
|
||||||
|
* `/api/payments_post.php`
|
||||||
|
* `/api/vendors_post.php`
|
||||||
|
* `/api/bills_post.php`
|
||||||
|
* `/api/reports/trial_balance.php`
|
||||||
|
* `/api/reports/income_statement.php`
|
||||||
|
* `/api/reports/balance_sheet.php`
|
||||||
|
* `/api/reports/ar_aging.php`
|
||||||
|
* `/api/reports/ap_aging.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
*This README provides a high-level overview. For detailed API specifications and implementation details, please refer to the source code.*
|
||||||
29
api/accounting_periods_close.php
Normal file
29
api/accounting_periods_close.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../db/config.php';
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($data['period_id'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing period_id.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("UPDATE accounting_periods SET status = 'closed' WHERE id = ?");
|
||||||
|
$stmt->execute([$data['period_id']]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() > 0) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Accounting period closed successfully.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Accounting period not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
24
api/accounting_periods_post.php
Normal file
24
api/accounting_periods_post.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../db/config.php';
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($data['period_name']) || empty($data['start_date']) || empty($data['end_date'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing required fields.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO accounting_periods (period_name, start_date, end_date) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$data['period_name'], $data['start_date'], $data['end_date']]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Accounting period created successfully.']);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
98
api/bill_payments_post.php
Normal file
98
api/bill_payments_post.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/journal_helpers.php';
|
||||||
|
require_once __DIR__ . '/../includes/audit_helpers.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($data['vendor_id']) || empty($data['payment_date']) || empty($data['payment_account_code']) || empty($data['amount']) || empty($data['bill_ids'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing required fields: vendor_id, payment_date, payment_account_code, amount, and bill_ids array.']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$total_payment_amount = (float)$data['amount'];
|
||||||
|
$ap_account_code = '2100-AP'; // Accounts Payable
|
||||||
|
|
||||||
|
// 1. Create Journal Entry to settle AP
|
||||||
|
$journal_lines = [
|
||||||
|
// Debit Accounts Payable
|
||||||
|
[
|
||||||
|
'account_code' => $ap_account_code,
|
||||||
|
'type' => 'DEBIT',
|
||||||
|
'amount' => $total_payment_amount
|
||||||
|
],
|
||||||
|
// Credit the cash/bank account used for payment
|
||||||
|
[
|
||||||
|
'account_code' => $data['payment_account_code'],
|
||||||
|
'type' => 'CREDIT',
|
||||||
|
'amount' => $total_payment_amount
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$journal_payload = [
|
||||||
|
'entry_date' => $data['payment_date'],
|
||||||
|
'description' => 'Payment for vendor ' . $data['vendor_id'],
|
||||||
|
'lines' => $journal_lines
|
||||||
|
];
|
||||||
|
|
||||||
|
$journal_result = post_journal_entry($pdo, $journal_payload);
|
||||||
|
if (empty($journal_result['journal_entry_id'])) {
|
||||||
|
throw new Exception('Failed to post journal entry: ' . ($journal_result['error'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
$journal_entry_id = $journal_result['journal_entry_id'];
|
||||||
|
|
||||||
|
// 2. Create the Payment Record
|
||||||
|
$payment_id = uuid_v4();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO payments (id, journal_entry_id, vendor_id, payment_date, amount, payment_method, reference_type) VALUES (?, ?, ?, ?, ?, ?, 'BILL')"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$payment_id,
|
||||||
|
$journal_entry_id,
|
||||||
|
$data['vendor_id'],
|
||||||
|
$data['payment_date'],
|
||||||
|
$total_payment_amount,
|
||||||
|
$data['payment_method'] ?? 'Bank Transfer'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Link payment to bills in payment_lines (and update bill statuses)
|
||||||
|
$stmt_line = $pdo->prepare("INSERT INTO payment_lines (id, payment_id, invoice_id) VALUES (?, ?, ?)"); // Reusing invoice_id for bill_id
|
||||||
|
$stmt_update_bill = $pdo->prepare("UPDATE bills SET status = 'PAID' WHERE bill_id = ?");
|
||||||
|
|
||||||
|
foreach($data['bill_ids'] as $bill_id) {
|
||||||
|
$stmt_line->execute([uuid_v4(), $payment_id, $bill_id]);
|
||||||
|
$stmt_update_bill->execute([$bill_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
log_audit_trail($pdo, 'pay_bill', ['payment_id' => $payment_id, 'vendor_id' => $data['vendor_id'], 'amount' => $total_payment_amount]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'success',
|
||||||
|
'payment_id' => $payment_id,
|
||||||
|
'journal_entry_id' => $journal_entry_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Payment failed: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
102
api/bills_post.php
Normal file
102
api/bills_post.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/journal_helpers.php';
|
||||||
|
require_once __DIR__ . '/../includes/audit_helpers.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (empty($data['vendor_id']) || empty($data['bill_date']) || empty($data['due_date']) || empty($data['lines']) || !is_array($data['lines'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing required fields: vendor_id, bill_date, due_date, and lines array.']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Calculate total amount and prepare journal entry lines
|
||||||
|
$total_amount = 0;
|
||||||
|
$journal_lines = [];
|
||||||
|
$ap_account_code = '2100-AP'; // Accounts Payable
|
||||||
|
|
||||||
|
foreach ($data['lines'] as $line) {
|
||||||
|
if (empty($line['expense_account_code']) || empty($line['amount'])) {
|
||||||
|
throw new Exception('Each bill line must have expense_account_code and amount.');
|
||||||
|
}
|
||||||
|
$amount = (float)$line['amount'];
|
||||||
|
$total_amount += $amount;
|
||||||
|
|
||||||
|
// Debit the expense account for this line
|
||||||
|
$journal_lines[] = [
|
||||||
|
'account_code' => $line['expense_account_code'],
|
||||||
|
'type' => 'DEBIT',
|
||||||
|
'amount' => $amount
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit Accounts Payable for the total amount
|
||||||
|
$journal_lines[] = [
|
||||||
|
'account_code' => $ap_account_code,
|
||||||
|
'type' => 'CREDIT',
|
||||||
|
'amount' => $total_amount
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Create the Journal Entry
|
||||||
|
$journal_entry_payload = [
|
||||||
|
'entry_date' => $data['bill_date'],
|
||||||
|
'description' => 'Vendor bill from ' . ($data['vendor_name'] ?? $data['vendor_id']),
|
||||||
|
'lines' => $journal_lines
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use a function to post the journal entry
|
||||||
|
$journal_entry_result = post_journal_entry($pdo, $journal_entry_payload);
|
||||||
|
if (empty($journal_entry_result['journal_entry_id'])) {
|
||||||
|
throw new Exception("Failed to post journal entry: " . ($journal_entry_result['error'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
$journal_entry_id = $journal_entry_result['journal_entry_id'];
|
||||||
|
|
||||||
|
// 3. Create the Bill
|
||||||
|
$bill_id = uuid_v4();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO bills (bill_id, vendor_id, journal_entry_id, bill_date, due_date, total_amount, status) VALUES (?, ?, ?, ?, ?, ?, 'SUBMITTED')"
|
||||||
|
);
|
||||||
|
$stmt->execute([$bill_id, $data['vendor_id'], $journal_entry_id, $data['bill_date'], $data['due_date'], $total_amount]);
|
||||||
|
|
||||||
|
// 4. Create Bill Lines
|
||||||
|
$stmt_lines = $pdo->prepare(
|
||||||
|
"INSERT INTO bill_lines (bill_line_id, bill_id, expense_account_code, description, amount) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
foreach ($data['lines'] as $line) {
|
||||||
|
$stmt_lines->execute([uuid_v4(), $bill_id, $line['expense_account_code'], $line['description'] ?? null, $line['amount']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
log_audit_trail($pdo, 'create_bill', ['bill_id' => $bill_id, 'vendor_id' => $data['vendor_id'], 'total_amount' => $total_amount]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
// 5. Fetch and return the created bill
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM bills WHERE bill_id = ?");
|
||||||
|
$stmt->execute([$bill_id]);
|
||||||
|
$new_bill = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode($new_bill);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Operation failed: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
96
api/fee_structures_post.php
Normal file
96
api/fee_structures_post.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
// api/fee_structures_post.php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid request.'
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$response['message'] = 'Invalid JSON payload.';
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($response);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (empty($input['name']) || empty($input['academic_year']) || empty($input['lines']) || !is_array($input['lines'])) {
|
||||||
|
$response['message'] = 'Missing required fields: name, academic_year, and lines array.';
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($response);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fee_structure_id = uuid_v4();
|
||||||
|
$status = $input['status'] ?? 'Draft';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO fee_structures (id, name, academic_year, status) VALUES (?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$fee_structure_id, $input['name'], $input['academic_year'], $status]);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
foreach ($input['lines'] as $line) {
|
||||||
|
if (empty($line['description']) || !isset($line['amount']) || empty($line['revenue_account_code'])) {
|
||||||
|
throw new Exception('Each line must have a description, amount, and revenue_account_code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get revenue_account_id from code
|
||||||
|
$stmt_acc = $pdo->prepare("SELECT id FROM accounts WHERE account_code = ? AND account_type = 'Revenue'");
|
||||||
|
$stmt_acc->execute([$line['revenue_account_code']]);
|
||||||
|
$revenue_account = $stmt_acc->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$revenue_account) {
|
||||||
|
throw new Exception("Invalid or non-revenue account code provided: {" . $line['revenue_account_code'] . "}");
|
||||||
|
}
|
||||||
|
$revenue_account_id = $revenue_account['id'];
|
||||||
|
|
||||||
|
$line_id = uuid_v4();
|
||||||
|
$stmt_line = $pdo->prepare(
|
||||||
|
"INSERT INTO fee_structure_lines (id, fee_structure_id, description, amount, revenue_account_id) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt_line->execute([$line_id, $fee_structure_id, $line['description'], $line['amount'], $revenue_account_id]);
|
||||||
|
|
||||||
|
$lines[] = [
|
||||||
|
'id' => $line_id,
|
||||||
|
'description' => $line['description'],
|
||||||
|
'amount' => $line['amount'],
|
||||||
|
'revenue_account_code' => $line['revenue_account_code']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$response['status'] = 'success';
|
||||||
|
$response['message'] = 'Fee structure created successfully.';
|
||||||
|
$response['data'] = [
|
||||||
|
'id' => $fee_structure_id,
|
||||||
|
'name' => $input['name'],
|
||||||
|
'academic_year' => $input['academic_year'],
|
||||||
|
'status' => $status,
|
||||||
|
'lines' => $lines
|
||||||
|
];
|
||||||
|
http_response_code(201);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
$response['message'] = 'Error creating fee structure: ' . $e->getMessage();
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response);
|
||||||
115
api/invoices_post.php
Normal file
115
api/invoices_post.php
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
// api/invoices_post.php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../includes/journal_helpers.php';
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid request.'
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE || empty($input['student_id']) || empty($input['fee_structure_id'])) {
|
||||||
|
$response['message'] = 'Invalid JSON payload or missing student_id/fee_structure_id.';
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($response);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Fetch Fee Structure details
|
||||||
|
$stmt_fsl = $pdo->prepare("SELECT fsl.amount, a.account_code FROM fee_structure_lines fsl JOIN accounts a ON fsl.revenue_account_id = a.id WHERE fsl.fee_structure_id = ?");
|
||||||
|
$stmt_fsl->execute([$input['fee_structure_id']]);
|
||||||
|
$fee_structure_lines = $stmt_fsl->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if (empty($fee_structure_lines)) throw new Exception('Fee structure has no lines or revenue accounts not found.');
|
||||||
|
|
||||||
|
// 2. Create Invoice
|
||||||
|
$total_amount = array_sum(array_column($fee_structure_lines, 'amount'));
|
||||||
|
$invoice_id = uuid_v4();
|
||||||
|
$invoice_number = 'INV-' . time();
|
||||||
|
$invoice_date = $input['invoice_date'] ?? date('Y-m-d');
|
||||||
|
$due_date = $input['due_date'] ?? date('Y-m-d', strtotime('+30 days'));
|
||||||
|
|
||||||
|
$stmt_inv = $pdo->prepare(
|
||||||
|
"INSERT INTO invoices (id, student_id, invoice_number, invoice_date, due_date, total_amount, status) VALUES (?, ?, ?, ?, ?, ?, 'Posted')"
|
||||||
|
);
|
||||||
|
$stmt_inv->execute([$invoice_id, $input['student_id'], $invoice_number, $invoice_date, $due_date, $total_amount]);
|
||||||
|
|
||||||
|
// Don't need to insert invoice lines manually if they are based on fee structure, can be joined.
|
||||||
|
// For simplicity, let's assume the previous logic of creating them was desired.
|
||||||
|
// Re-creating invoice lines for atomicity.
|
||||||
|
foreach ($fee_structure_lines as $line) {
|
||||||
|
$stmt_inv_line = $pdo->prepare(
|
||||||
|
"INSERT INTO invoice_lines (id, invoice_id, description, amount, revenue_account_id) VALUES (?, ?, ?, ?, (SELECT id from accounts where account_code = ?))"
|
||||||
|
);
|
||||||
|
$stmt_inv_line->execute([uuid_v4(), $invoice_id, 'Fee', $line['amount'], $line['account_code']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 3. Prepare and Create Journal Entry
|
||||||
|
$journal_lines = [];
|
||||||
|
// Debit AR
|
||||||
|
$journal_lines[] = [
|
||||||
|
'account_code' => '1200-AR-STUDENTS',
|
||||||
|
'type' => 'DEBIT',
|
||||||
|
'amount' => $total_amount
|
||||||
|
];
|
||||||
|
// Credit Revenue accounts
|
||||||
|
foreach ($fee_structure_lines as $line) {
|
||||||
|
$journal_lines[] = [
|
||||||
|
'account_code' => $line['account_code'],
|
||||||
|
'type' => 'CREDIT',
|
||||||
|
'amount' => $line['amount']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$journal_payload = [
|
||||||
|
'entry_date' => $invoice_date,
|
||||||
|
'description' => "Invoice {$invoice_number} for student {$input['student_id']}",
|
||||||
|
'lines' => $journal_lines
|
||||||
|
];
|
||||||
|
|
||||||
|
$journal_result = post_journal_entry($pdo, $journal_payload);
|
||||||
|
if (empty($journal_result['journal_entry_id'])) {
|
||||||
|
throw new Exception("Failed to post journal entry: " . ($journal_result['error'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
$journal_entry_id = $journal_result['journal_entry_id'];
|
||||||
|
|
||||||
|
// Link Invoice to Journal Entry
|
||||||
|
$stmt_inv_update = $pdo->prepare("UPDATE invoices SET journal_entry_id = ? WHERE id = ?");
|
||||||
|
$stmt_inv_update->execute([$journal_entry_id, $invoice_id]);
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
log_audit_trail($pdo, 'create_invoice', ['invoice_id' => $invoice_id, 'student_id' => $input['student_id'], 'total_amount' => $total_amount]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Invoice created and journal entry posted successfully.',
|
||||||
|
'data' => [
|
||||||
|
'invoice_id' => $invoice_id,
|
||||||
|
'invoice_number' => $invoice_number,
|
||||||
|
'journal_entry_id' => $journal_entry_id
|
||||||
|
]
|
||||||
|
];
|
||||||
|
http_response_code(201);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
$response['message'] = 'Error creating invoice: ' . $e->getMessage();
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response);
|
||||||
123
api/payments_post.php
Normal file
123
api/payments_post.php
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
// api/payments_post.php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../includes/audit_helpers.php';
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Invalid request.'
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE || empty($input['student_id']) || empty($input['amount_received']) || empty($input['payment_method']) || empty($input['cash_account_code']) || empty($input['lines'])) {
|
||||||
|
$response['message'] = 'Invalid JSON payload or missing required fields.';
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($response);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Fetch Student and Accounts
|
||||||
|
$stmt_student = $pdo->prepare("SELECT subledger_id FROM students WHERE id = ?");
|
||||||
|
$stmt_student->execute([$input['student_id']]);
|
||||||
|
$student_subledger_id = $stmt_student->fetchColumn();
|
||||||
|
if (!$student_subledger_id) throw new Exception('Student not found.');
|
||||||
|
|
||||||
|
$stmt_ar = $pdo->prepare("SELECT id FROM accounts WHERE account_code = '1200-AR-STUDENTS'");
|
||||||
|
$stmt_ar->execute();
|
||||||
|
$ar_account_id = $stmt_ar->fetchColumn();
|
||||||
|
if (!$ar_account_id) throw new Exception('AR account not found.');
|
||||||
|
|
||||||
|
$stmt_cash = $pdo->prepare("SELECT id FROM accounts WHERE account_code = ? AND account_type = 'Asset'");
|
||||||
|
$stmt_cash->execute([$input['cash_account_code']]);
|
||||||
|
$cash_account_id = $stmt_cash->fetchColumn();
|
||||||
|
if (!$cash_account_id) throw new Exception('Invalid cash/asset account code.');
|
||||||
|
|
||||||
|
// 2. Create Payment
|
||||||
|
$payment_id = uuid_v4();
|
||||||
|
$payment_date = $input['payment_date'] ?? date('Y-m-d');
|
||||||
|
$stmt_payment = $pdo->prepare(
|
||||||
|
"INSERT INTO payments (id, student_id, payment_date, amount_received, payment_method, reference_number) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt_payment->execute([$payment_id, $input['student_id'], $payment_date, $input['amount_received'], $input['payment_method'], $input['reference_number'] ?? null]);
|
||||||
|
|
||||||
|
// 3. Create Payment Lines and Update Invoices
|
||||||
|
$total_applied = 0;
|
||||||
|
foreach ($input['lines'] as $line) {
|
||||||
|
$total_applied += $line['amount_applied'];
|
||||||
|
$stmt_pl = $pdo->prepare("INSERT INTO payment_lines (id, payment_id, invoice_id, amount_applied) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt_pl->execute([uuid_v4(), $payment_id, $line['invoice_id'], $line['amount_applied']]);
|
||||||
|
|
||||||
|
// Check if invoice is fully paid
|
||||||
|
$stmt_inv_total = $pdo->prepare("SELECT total_amount FROM invoices WHERE id = ?");
|
||||||
|
$stmt_inv_total->execute([$line['invoice_id']]);
|
||||||
|
$invoice_total = $stmt_inv_total->fetchColumn();
|
||||||
|
|
||||||
|
$stmt_paid_total = $pdo->prepare("SELECT SUM(amount_applied) FROM payment_lines WHERE invoice_id = ?");
|
||||||
|
$stmt_paid_total->execute([$line['invoice_id']]);
|
||||||
|
$total_paid_for_invoice = $stmt_paid_total->fetchColumn();
|
||||||
|
|
||||||
|
if ($total_paid_for_invoice >= $invoice_total) {
|
||||||
|
$stmt_inv_update = $pdo->prepare("UPDATE invoices SET status = 'Paid' WHERE id = ?");
|
||||||
|
$stmt_inv_update->execute([$line['invoice_id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bccomp($total_applied, $input['amount_received'], 2) != 0) {
|
||||||
|
throw new Exception('Sum of amounts applied does not match amount received.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Journal Entry
|
||||||
|
$journal_entry_id = uuid_v4();
|
||||||
|
$description = "Payment {$input['reference_number']} for student {$input['student_id']}";
|
||||||
|
$stmt_je = $pdo->prepare(
|
||||||
|
"INSERT INTO journal_entries (id, entry_date, description, status, posted_at) VALUES (?, ?, ?, 'Posted', NOW())"
|
||||||
|
);
|
||||||
|
$stmt_je->execute([$journal_entry_id, $payment_date, $description]);
|
||||||
|
|
||||||
|
// Debit Cash/Bank
|
||||||
|
$stmt_jel_dr = $pdo->prepare("INSERT INTO journal_entry_lines (id, journal_entry_id, account_id, debit) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt_jel_dr->execute([uuid_v4(), $journal_entry_id, $cash_account_id, $input['amount_received']]);
|
||||||
|
|
||||||
|
// Credit AR
|
||||||
|
$stmt_jel_cr = $pdo->prepare(
|
||||||
|
"INSERT INTO journal_entry_lines (id, journal_entry_id, account_id, subledger_id, credit) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt_jel_cr->execute([uuid_v4(), $journal_entry_id, $ar_account_id, $student_subledger_, $input['amount_received']]);
|
||||||
|
|
||||||
|
// Link Payment to Journal Entry
|
||||||
|
$stmt_payment_update = $pdo->prepare("UPDATE payments SET journal_entry_id = ? WHERE id = ?");
|
||||||
|
$stmt_payment_update->execute([$journal_entry_id, $payment_id]);
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
log_audit_trail($pdo, 'record_payment', ['payment_id' => $payment_id, 'student_id' => $input['student_id'], 'amount' => $input['amount_received']]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Payment recorded and journal entry posted successfully.',
|
||||||
|
'data' => [
|
||||||
|
'payment_id' => $payment_id,
|
||||||
|
'journal_entry_id' => $journal_entry_id
|
||||||
|
]
|
||||||
|
];
|
||||||
|
http_response_code(201);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
$response['message'] = 'Error recording payment: ' . $e->getMessage();
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response, JSON_PRETTY_PRINT);
|
||||||
86
api/reports/ap_aging.php
Normal file
86
api/reports/ap_aging.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../../db/config.php';
|
||||||
|
|
||||||
|
//
|
||||||
|
// ## Accounts Payable Aging Report
|
||||||
|
//
|
||||||
|
// Purpose: Show outstanding vendor balances grouped by age.
|
||||||
|
//
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
s.subledger_id as vendor_id,
|
||||||
|
s.name as vendor_name,
|
||||||
|
b.id as bill_id,
|
||||||
|
b.bill_date,
|
||||||
|
b.due_date,
|
||||||
|
DATEDIFF(:as_of_date, b.due_date) as days_overdue,
|
||||||
|
(b.total_amount - COALESCE(p.paid_amount, 0)) as outstanding_balance
|
||||||
|
FROM bills b
|
||||||
|
JOIN subledgers s ON b.vendor_id = s.subledger_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT reference_id, SUM(amount) as paid_amount
|
||||||
|
FROM payment_lines
|
||||||
|
JOIN payments ON payments.id = payment_lines.payment_id
|
||||||
|
WHERE payments.reference_type = 'bill'
|
||||||
|
GROUP BY reference_id
|
||||||
|
) p ON b.id = p.reference_id
|
||||||
|
WHERE (b.total_amount - COALESCE(p.paid_amount, 0)) > 0.01
|
||||||
|
AND b.bill_date <= :as_of_date
|
||||||
|
ORDER BY s.name, b.bill_date;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([':as_of_date' => $as_of_date]);
|
||||||
|
$details = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Bucket the outstanding balances
|
||||||
|
$buckets = [
|
||||||
|
'current' => 0,
|
||||||
|
'1-30' => 0,
|
||||||
|
'31-60' => 0,
|
||||||
|
'61-90' => 0,
|
||||||
|
'91+' => 0
|
||||||
|
];
|
||||||
|
$total_outstanding = 0;
|
||||||
|
|
||||||
|
foreach ($details as $detail) {
|
||||||
|
$total_outstanding += $detail['outstanding_balance'];
|
||||||
|
if ($detail['days_overdue'] <= 0) {
|
||||||
|
$buckets['current'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 30) {
|
||||||
|
$buckets['1-30'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 60) {
|
||||||
|
$buckets['31-60'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 90) {
|
||||||
|
$buckets['61-90'] += $detail['outstanding_balance'];
|
||||||
|
} else {
|
||||||
|
$buckets['91+'] += $detail['outstanding_balance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'report_name' => 'Accounts Payable Aging',
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'filters' => ['as_of_date' => $as_of_date],
|
||||||
|
'summary' => [
|
||||||
|
'total_outstanding' => $total_outstanding,
|
||||||
|
'buckets' => $buckets
|
||||||
|
],
|
||||||
|
'details' => $details
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Database error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
84
api/reports/ar_aging.php
Normal file
84
api/reports/ar_aging.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../../db/config.php';
|
||||||
|
|
||||||
|
//
|
||||||
|
// ## Accounts Receivable Aging Report
|
||||||
|
//
|
||||||
|
// Purpose: Show outstanding customer balances grouped by age.
|
||||||
|
//
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
s.subledger_id,
|
||||||
|
s.name as student_name,
|
||||||
|
i.id as invoice_id,
|
||||||
|
i.invoice_date,
|
||||||
|
i.due_date,
|
||||||
|
DATEDIFF(:as_of_date, i.due_date) as days_overdue,
|
||||||
|
(i.total_amount - COALESCE(p.paid_amount, 0)) as outstanding_balance
|
||||||
|
FROM invoices i
|
||||||
|
JOIN subledgers s ON i.student_id = s.subledger_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT invoice_id, SUM(amount) as paid_amount
|
||||||
|
FROM payment_lines
|
||||||
|
GROUP BY invoice_id
|
||||||
|
) p ON i.id = p.invoice_id
|
||||||
|
WHERE (i.total_amount - COALESCE(p.paid_amount, 0)) > 0.01
|
||||||
|
AND i.invoice_date <= :as_of_date
|
||||||
|
ORDER BY s.name, i.invoice_date;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([':as_of_date' => $as_of_date]);
|
||||||
|
$details = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Bucket the outstanding balances
|
||||||
|
$buckets = [
|
||||||
|
'current' => 0,
|
||||||
|
'1-30' => 0,
|
||||||
|
'31-60' => 0,
|
||||||
|
'61-90' => 0,
|
||||||
|
'91+' => 0
|
||||||
|
];
|
||||||
|
$total_outstanding = 0;
|
||||||
|
|
||||||
|
foreach ($details as $detail) {
|
||||||
|
$total_outstanding += $detail['outstanding_balance'];
|
||||||
|
if ($detail['days_overdue'] <= 0) {
|
||||||
|
$buckets['current'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 30) {
|
||||||
|
$buckets['1-30'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 60) {
|
||||||
|
$buckets['31-60'] += $detail['outstanding_balance'];
|
||||||
|
} elseif ($detail['days_overdue'] <= 90) {
|
||||||
|
$buckets['61-90'] += $detail['outstanding_balance'];
|
||||||
|
} else {
|
||||||
|
$buckets['91+'] += $detail['outstanding_balance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'report_name' => 'Accounts Receivable Aging',
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'filters' => ['as_of_date' => $as_of_date],
|
||||||
|
'summary' => [
|
||||||
|
'total_outstanding' => $total_outstanding,
|
||||||
|
'buckets' => $buckets
|
||||||
|
],
|
||||||
|
'details' => $details
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Database error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
126
api/reports/balance_sheet.php
Normal file
126
api/reports/balance_sheet.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../../db/config.php';
|
||||||
|
|
||||||
|
//
|
||||||
|
// ## Balance Sheet Report
|
||||||
|
//
|
||||||
|
// Purpose: Show financial position (Assets = Liabilities + Equity) at a specific date.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
//
|
||||||
|
// **Rule (Absolute):** Reads ONLY from `accounts` and `journal_lines` for posted entries.
|
||||||
|
// It includes retained earnings (net income from prior periods).
|
||||||
|
//
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- 1. Calculate Net Income (Retained Earnings) ---
|
||||||
|
// This is the sum of all revenue and expense accounts from the beginning of time up to the report date.
|
||||||
|
$retained_earnings_sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
SUM(CASE
|
||||||
|
WHEN a.account_type = 'revenue' THEN (jl.credit_amount - jl.debit_amount)
|
||||||
|
WHEN a.account_type = 'expense' THEN (jl.debit_amount - jl.credit_amount)
|
||||||
|
ELSE 0
|
||||||
|
END) as net_income
|
||||||
|
FROM journal_lines jl
|
||||||
|
JOIN accounts a ON jl.account_code = a.account_code
|
||||||
|
JOIN journal_entries je ON jl.journal_entry_id = je.id
|
||||||
|
WHERE je.status = 'posted' AND a.account_type IN ('revenue', 'expense') AND je.entry_date <= :as_of_date;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$stmt_re = $pdo->prepare($retained_earnings_sql);
|
||||||
|
$stmt_re->execute([':as_of_date' => $as_of_date]);
|
||||||
|
$retained_earnings = $stmt_re->fetchColumn() ?: 0;
|
||||||
|
|
||||||
|
// --- 2. Get Balances for Asset, Liability, and Equity Accounts ---
|
||||||
|
$main_sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
a.account_code,
|
||||||
|
a.account_name,
|
||||||
|
a.account_type,
|
||||||
|
a.normal_balance,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN a.normal_balance = 'debit' THEN jl.debit_amount - jl.credit_amount
|
||||||
|
ELSE jl.credit_amount - jl.debit_amount
|
||||||
|
END) as balance
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_lines jl ON a.account_code = jl.account_code
|
||||||
|
JOIN journal_entries je ON jl.journal_entry_id = je.id
|
||||||
|
WHERE
|
||||||
|
je.status = 'posted'
|
||||||
|
AND a.account_type IN ('asset', 'liability', 'equity')
|
||||||
|
AND je.entry_date <= :as_of_date
|
||||||
|
GROUP BY a.account_code, a.account_name, a.account_type, a.normal_balance
|
||||||
|
HAVING balance != 0
|
||||||
|
ORDER BY a.account_type, a.account_code;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$stmt_main = $pdo->prepare($main_sql);
|
||||||
|
$stmt_main->execute([':as_of_date' => $as_of_date]);
|
||||||
|
$report_lines = $stmt_main->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// --- 3. Structure the report and calculate totals ---
|
||||||
|
$assets_total = 0;
|
||||||
|
$liabilities_total = 0;
|
||||||
|
$equity_total = 0;
|
||||||
|
$assets = [];
|
||||||
|
$liabilities = [];
|
||||||
|
$equity = [];
|
||||||
|
|
||||||
|
foreach ($report_lines as $line) {
|
||||||
|
switch ($line['account_type']) {
|
||||||
|
case 'asset':
|
||||||
|
$assets[] = $line;
|
||||||
|
$assets_total += $line['balance'];
|
||||||
|
break;
|
||||||
|
case 'liability':
|
||||||
|
$liabilities[] = $line;
|
||||||
|
$liabilities_total += $line['balance'];
|
||||||
|
break;
|
||||||
|
case 'equity':
|
||||||
|
$equity[] = $line;
|
||||||
|
$equity_total += $line['balance'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add retained earnings to equity section
|
||||||
|
$equity_total += $retained_earnings;
|
||||||
|
$total_liabilities_and_equity = $liabilities_total + $equity_total;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'report_name' => 'Balance Sheet',
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'filters' => ['as_of_date' => $as_of_date],
|
||||||
|
'data' => [
|
||||||
|
'assets' => $assets,
|
||||||
|
'liabilities' => $liabilities,
|
||||||
|
'equity' => array_merge($equity, [[
|
||||||
|
'account_code' => '3999',
|
||||||
|
'account_name' => 'Retained Earnings',
|
||||||
|
'account_type' => 'equity',
|
||||||
|
'balance' => $retained_earnings
|
||||||
|
]])
|
||||||
|
],
|
||||||
|
'summary' => [
|
||||||
|
'total_assets' => $assets_total,
|
||||||
|
'total_liabilities' => $liabilities_total,
|
||||||
|
'total_equity' => $equity_total,
|
||||||
|
'total_liabilities_and_equity' => $total_liabilities_and_equity,
|
||||||
|
'is_balanced' => abs($assets_total - $total_liabilities_and_equity) < 0.0001
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Database error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
100
api/reports/income_statement.php
Normal file
100
api/reports/income_statement.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../../db/config.php';
|
||||||
|
|
||||||
|
//
|
||||||
|
// ## Income Statement Report (Profit & Loss)
|
||||||
|
//
|
||||||
|
// Purpose: Show financial performance (Revenue - Expenses) over a period.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
//
|
||||||
|
// **Rule (Absolute):** Reads ONLY from `accounts` and `journal_lines` for posted entries.
|
||||||
|
// Filters for accounts of type 'revenue' and 'expense'.
|
||||||
|
//
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Optional date range filtering
|
||||||
|
$start_date = $_GET['start_date'] ?? null;
|
||||||
|
$end_date = $_GET['end_date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
a.account_code,
|
||||||
|
a.account_name,
|
||||||
|
a.account_type,
|
||||||
|
a.normal_balance,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN a.normal_balance = 'debit' THEN jl.debit_amount - jl.credit_amount
|
||||||
|
ELSE jl.credit_amount - jl.debit_amount
|
||||||
|
END) as balance
|
||||||
|
FROM
|
||||||
|
accounts a
|
||||||
|
JOIN
|
||||||
|
journal_lines jl ON a.account_code = jl.account_code
|
||||||
|
JOIN
|
||||||
|
journal_entries je ON jl.journal_entry_id = je.id
|
||||||
|
WHERE
|
||||||
|
je.status = 'posted'
|
||||||
|
AND a.account_type IN ('revenue', 'expense')
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
if ($start_date) {
|
||||||
|
$sql .= " AND je.entry_date >= :start_date";
|
||||||
|
$params[':start_date'] = $start_date;
|
||||||
|
}
|
||||||
|
if ($end_date) {
|
||||||
|
$sql .= " AND je.entry_date <= :end_date";
|
||||||
|
$params[':end_date'] = $end_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " GROUP BY a.account_code, a.account_name, a.account_type, a.normal_balance ORDER BY a.account_type, a.account_code";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$report_lines = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Structure the report and calculate totals
|
||||||
|
$revenue_total = 0;
|
||||||
|
$expense_total = 0;
|
||||||
|
$revenues = [];
|
||||||
|
$expenses = [];
|
||||||
|
|
||||||
|
foreach ($report_lines as $line) {
|
||||||
|
if ($line['account_type'] == 'revenue') {
|
||||||
|
$revenues[] = $line;
|
||||||
|
$revenue_total += $line['balance'];
|
||||||
|
} else {
|
||||||
|
$expenses[] = $line;
|
||||||
|
$expense_total += $line['balance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$net_income = $revenue_total - $expense_total;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'report_name' => 'Income Statement',
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'filters' => ['start_date' => $start_date, 'end_date' => $end_date],
|
||||||
|
'data' => [
|
||||||
|
'revenues' => $revenues,
|
||||||
|
'expenses' => $expenses
|
||||||
|
],
|
||||||
|
'summary' => [
|
||||||
|
'total_revenue' => $revenue_total,
|
||||||
|
'total_expense' => $expense_total,
|
||||||
|
'net_income' => $net_income
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Database error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
75
api/reports/trial_balance.php
Normal file
75
api/reports/trial_balance.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '../../../db/config.php';
|
||||||
|
|
||||||
|
//
|
||||||
|
// ## Trial Balance Report
|
||||||
|
//
|
||||||
|
// Purpose: Prove that the double-entry system balances.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
//
|
||||||
|
// **Rule (Absolute):** This report reads ONLY from the General Ledger (`journal_entries`, `journal_lines`, `accounts`).
|
||||||
|
// It NEVER reads from operational tables like `invoices` or `payments`.
|
||||||
|
//
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The query calculates the total debits and credits for each account from posted journal entries.
|
||||||
|
// It also computes the final balance based on the account's normal balance.
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
a.account_code,
|
||||||
|
a.account_name,
|
||||||
|
a.account_type,
|
||||||
|
a.normal_balance,
|
||||||
|
COALESCE(SUM(jl.debit_amount), 0) as total_debits,
|
||||||
|
COALESCE(SUM(jl.credit_amount), 0) as total_credits,
|
||||||
|
CASE
|
||||||
|
WHEN a.normal_balance = 'debit' THEN COALESCE(SUM(jl.debit_amount), 0) - COALESCE(SUM(jl.credit_amount), 0)
|
||||||
|
ELSE COALESCE(SUM(jl.credit_amount), 0) - COALESCE(SUM(jl.debit_amount), 0)
|
||||||
|
END as balance
|
||||||
|
FROM
|
||||||
|
accounts a
|
||||||
|
LEFT JOIN
|
||||||
|
journal_lines jl ON a.account_code = jl.account_code
|
||||||
|
LEFT JOIN
|
||||||
|
journal_entries je ON jl.journal_entry_id = je.id AND je.status = 'posted'
|
||||||
|
GROUP BY
|
||||||
|
a.account_code, a.account_name, a.account_type, a.normal_balance
|
||||||
|
ORDER BY
|
||||||
|
a.account_code;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute();
|
||||||
|
$report_lines = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Calculate totals for debits, credits to verify balance
|
||||||
|
$total_debits = 0;
|
||||||
|
$total_credits = 0;
|
||||||
|
foreach ($report_lines as $line) {
|
||||||
|
$total_debits += $line['total_debits'];
|
||||||
|
$total_credits += $line['total_credits'];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'report_name' => 'Trial Balance',
|
||||||
|
'generated_at' => date('c'),
|
||||||
|
'data' => $report_lines,
|
||||||
|
'summary' => [
|
||||||
|
'total_debits' => $total_debits,
|
||||||
|
'total_credits' => $total_credits,
|
||||||
|
'is_balanced' => abs($total_debits - $total_credits) < 0.0001 // Use a small tolerance for floating point comparison
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Database error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
73
api/students_post.php
Normal file
73
api/students_post.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
|
||||||
|
$db = db();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
$required_fields = ['student_number', 'first_name', 'last_name', 'date_of_birth', 'gender', 'admission_date'];
|
||||||
|
foreach ($required_fields as $field) {
|
||||||
|
if (empty($data[$field])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => "Missing required field: {$field}"]);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create Subledger Entry
|
||||||
|
$subledger_id = UUID::v4();
|
||||||
|
$student_id = UUID::v4(); // Generate student ID in advance
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
'INSERT INTO subledgers (id, subledger_type, reference_id) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$subledger_id, 'Student', $student_id]);
|
||||||
|
|
||||||
|
// 2. Create Student Entry
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
'INSERT INTO students (id, student_number, first_name, middle_name, last_name, date_of_birth, gender, admission_date, subledger_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$student_id,
|
||||||
|
$data['student_number'],
|
||||||
|
$data['first_name'],
|
||||||
|
$data['middle_name'] ?? null,
|
||||||
|
$data['last_name'],
|
||||||
|
$data['date_of_birth'],
|
||||||
|
$data['gender'],
|
||||||
|
$data['admission_date'],
|
||||||
|
$subledger_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
// Fetch the created student to return it
|
||||||
|
$stmt = $db->prepare('SELECT * FROM students WHERE id = ?');
|
||||||
|
$stmt->execute([$student_id]);
|
||||||
|
$student = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode($student);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
// Check for unique constraint violation for student_number
|
||||||
|
if ($e->errorInfo[1] == 1062) { // 1062 is the MySQL error code for duplicate entry
|
||||||
|
echo json_encode(['error' => 'A student with this student number already exists.', 'details' => $e->getMessage()]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['error' => 'Database error occurred.', 'details' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
api/vendors_post.php
Normal file
60
api/vendors_post.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($data['vendor_name'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Vendor name is required']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Create subledger entry for the vendor
|
||||||
|
$subledger_id = uuid_v4();
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO subledgers (subledger_id, subledger_type, reference_id) VALUES (?, 'Vendor', ?)");
|
||||||
|
$vendor_id = uuid_v4(); // Generate vendor ID ahead of time to link it
|
||||||
|
$stmt->execute([$subledger_id, $vendor_id]);
|
||||||
|
|
||||||
|
// 2. Create the vendor
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO vendors (vendor_id, subledger_id, vendor_name, contact_person, email, phone, address) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$vendor_id,
|
||||||
|
$subledger_id,
|
||||||
|
$data['vendor_name'],
|
||||||
|
$data['contact_person'] ?? null,
|
||||||
|
$data['email'] ?? null,
|
||||||
|
$data['phone'] ?? null,
|
||||||
|
$data['address'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
// Fetch and return the created vendor data
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM vendors WHERE vendor_id = ?");
|
||||||
|
$stmt->execute([$vendor_id]);
|
||||||
|
$new_vendor = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode($new_vendor);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Database error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
79
dashboard/assets/css/style.css
Normal file
79
dashboard/assets/css/style.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f4f7fa;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li a:hover, .sidebar li a.active {
|
||||||
|
background-color: #eef2f5;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
20
dashboard/index.html
Normal file
20
dashboard/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>School Accounting Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="dashboard/assets/css/style.css">
|
||||||
|
<!-- React and Babel for JSX transpilation -->
|
||||||
|
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!-- React Application -->
|
||||||
|
<script type="text/babel" src="dashboard/src/App.js"></script>
|
||||||
|
<script type="text/babel" src="dashboard/src/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
dashboard/src/App.js
Normal file
76
dashboard/src/App.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
const App = () => {
|
||||||
|
// State to hold trial balance data
|
||||||
|
const [trialBalance, setTrialBalance] = React.useState(null);
|
||||||
|
const [error, setError] = React.useState(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Fetch trial balance data
|
||||||
|
fetch('/api/reports/trial_balance.php')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
setTrialBalance(data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error fetching trial balance:", error);
|
||||||
|
setError(error.toString());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#" className="active">Overview</a></li>
|
||||||
|
<li><a href="#">Reports</a></li>
|
||||||
|
<li><a href="#">Students</a></li>
|
||||||
|
<li><a href="#">Invoices</a></li>
|
||||||
|
<li><a href="#">Payments</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<main className="main-content">
|
||||||
|
<header className="header">
|
||||||
|
<h1>Financial Overview</h1>
|
||||||
|
</header>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Trial Balance</h3>
|
||||||
|
{error && <p>Error loading data: {error}</p>}
|
||||||
|
{!trialBalance && !error && <p>Loading...</p>}
|
||||||
|
{trialBalance && (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Debit</th>
|
||||||
|
<th>Credit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{trialBalance.lines.map((line, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{line.account_name}</td>
|
||||||
|
<td>{line.debit}</td>
|
||||||
|
<td>{line.credit}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td><b>{trialBalance.totals.account_name}</b></td>
|
||||||
|
<td><b>{trialBalance.totals.debit}</b></td>
|
||||||
|
<td><b>{trialBalance.totals.credit}</b></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
dashboard/src/index.js
Normal file
1
dashboard/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
64
db/migrate.php
Normal file
64
db/migrate.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/../includes/uuid.php';
|
||||||
|
|
||||||
|
$_SESSION['migration_output'] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (migration VARCHAR(255) PRIMARY KEY);');
|
||||||
|
|
||||||
|
$executed_migrations_stmt = $pdo->query('SELECT migration FROM migrations');
|
||||||
|
$executed_migrations = $executed_migrations_stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$migration_files = glob(__DIR__ . '/migrations/*.{sql,php}', GLOB_BRACE);
|
||||||
|
sort($migration_files);
|
||||||
|
|
||||||
|
$ran_a_migration = false;
|
||||||
|
|
||||||
|
foreach ($migration_files as $migration_file) {
|
||||||
|
$migration_name = basename($migration_file);
|
||||||
|
|
||||||
|
if (!in_array($migration_name, $executed_migrations)) {
|
||||||
|
$ran_a_migration = true;
|
||||||
|
$_SESSION['migration_output'][] = "Running migration: {$migration_name}...";
|
||||||
|
|
||||||
|
$extension = pathinfo($migration_file, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
if ($extension === 'sql') {
|
||||||
|
$sql = file_get_contents($migration_file);
|
||||||
|
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
if (!empty($statement)) {
|
||||||
|
$pdo->exec($statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($extension === 'php') {
|
||||||
|
require $migration_file;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (?)');
|
||||||
|
$stmt->execute([$migration_name]);
|
||||||
|
|
||||||
|
$_SESSION['migration_output'][] = "Successfully ran and recorded migration: {$migration_name}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ran_a_migration) {
|
||||||
|
$_SESSION['migration_output'][] = "No new migrations to run. Database is up to date.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['migration_status'] = 'success';
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$_SESSION['migration_status'] = 'error';
|
||||||
|
$_SESSION['migration_output'][] = 'An error occurred: ' . $e->getMessage();
|
||||||
|
error_log('Migration Error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /index.php?migration_status=' . $_SESSION['migration_status']);
|
||||||
|
exit;
|
||||||
107
db/migrations/001_initial_schema.sql
Normal file
107
db/migrations/001_initial_schema.sql
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
-- MySQL-compatible Schema for Ghana School ERP Accounting Module
|
||||||
|
-- Translated from user-provided PostgreSQL schema.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `accounts` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`account_code` VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
`account_name` VARCHAR(255) NOT NULL,
|
||||||
|
`account_type` VARCHAR(20) NOT NULL CHECK (`account_type` IN ('Asset', 'Liability', 'Equity', 'Revenue', 'ContraRevenue', 'Expense')),
|
||||||
|
`normal_balance` VARCHAR(6) NOT NULL CHECK (`normal_balance` IN ('Debit', 'Credit')),
|
||||||
|
`parent_account_id` CHAR(36),
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (`parent_account_id`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_accounts_code` ON `accounts`(`account_code`);
|
||||||
|
CREATE INDEX `idx_accounts_parent` ON `accounts`(`parent_account_id`);
|
||||||
|
CREATE INDEX `idx_accounts_type` ON `accounts`(`account_type`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `accounting_periods` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`start_date` DATE NOT NULL,
|
||||||
|
`end_date` DATE NOT NULL,
|
||||||
|
`is_closed` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `chk_period_dates` CHECK (`end_date` > `start_date`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_periods_dates` ON `accounting_periods`(`start_date`, `end_date`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `journal_entries` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`entry_number` VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
`entry_date` DATE NOT NULL,
|
||||||
|
`description` TEXT,
|
||||||
|
`period_id` CHAR(36) NOT NULL,
|
||||||
|
`source_module` VARCHAR(20) NOT NULL CHECK (`source_module` IN ('Manual', 'Fees', 'Payments', 'Payroll', 'Expenses')),
|
||||||
|
`source_reference_id` CHAR(36),
|
||||||
|
`status` VARCHAR(10) NOT NULL DEFAULT 'Draft' CHECK (`status` IN ('Draft', 'Posted', 'Reversed')),
|
||||||
|
`reversed_by` CHAR(36),
|
||||||
|
`reverses_journal_id` CHAR(36),
|
||||||
|
`created_by` CHAR(36) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`posted_at` TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (`period_id`) REFERENCES `accounting_periods`(`id`) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (`reversed_by`) REFERENCES `journal_entries`(`id`),
|
||||||
|
FOREIGN KEY (`reverses_journal_id`) REFERENCES `journal_entries`(`id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_journal_period` ON `journal_entries`(`period_id`);
|
||||||
|
CREATE INDEX `idx_journal_date` ON `journal_entries`(`entry_date`);
|
||||||
|
CREATE INDEX `idx_journal_status` ON `journal_entries`(`status`);
|
||||||
|
CREATE INDEX `idx_journal_source` ON `journal_entries`(`source_module`, `source_reference_id`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `journal_lines` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`journal_entry_id` CHAR(36) NOT NULL,
|
||||||
|
`account_id` CHAR(36) NOT NULL,
|
||||||
|
`debit_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||||
|
`credit_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||||
|
`line_description` TEXT,
|
||||||
|
CONSTRAINT `chk_debit_or_credit` CHECK ((`debit_amount` > 0 AND `credit_amount` = 0) OR (`credit_amount` > 0 AND `debit_amount` = 0)),
|
||||||
|
CHECK (`debit_amount` >= 0),
|
||||||
|
CHECK (`credit_amount` >= 0),
|
||||||
|
FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_lines_journal` ON `journal_lines`(`journal_entry_id`);
|
||||||
|
CREATE INDEX `idx_lines_account` ON `journal_lines`(`account_id`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `subledgers` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`subledger_type` VARCHAR(20) NOT NULL CHECK (`subledger_type` IN ('Student', 'Vendor', 'Staff')),
|
||||||
|
`reference_id` CHAR(36) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `unq_subledger` UNIQUE (`subledger_type`, `reference_id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_subledger_ref` ON `subledgers`(`subledger_type`, `reference_id`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `journal_line_subledgers` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`journal_line_id` CHAR(36) NOT NULL,
|
||||||
|
`subledger_id` CHAR(36) NOT NULL,
|
||||||
|
CONSTRAINT `unq_line_subledger` UNIQUE (`journal_line_id`, `subledger_id`),
|
||||||
|
FOREIGN KEY (`journal_line_id`) REFERENCES `journal_lines`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`subledger_id`) REFERENCES `subledgers`(`id`) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_jls_line` ON `journal_line_subledgers`(`journal_line_id`);
|
||||||
|
CREATE INDEX `idx_jls_subledger` ON `journal_line_subledgers`(`subledger_id`);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||||
|
`id` CHAR(36) PRIMARY KEY,
|
||||||
|
`entity_type` VARCHAR(50) NOT NULL,
|
||||||
|
`entity_id` CHAR(36) NOT NULL,
|
||||||
|
`action` VARCHAR(20) NOT NULL CHECK (`action` IN ('CREATE', 'UPDATE', 'POST', 'REVERSE', 'DELETE')),
|
||||||
|
`performed_by` CHAR(36) NOT NULL,
|
||||||
|
`performed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`details` JSON
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `idx_audit_entity` ON `audit_logs`(`entity_type`, `entity_id`);
|
||||||
|
CREATE INDEX `idx_audit_performed` ON `audit_logs`(`performed_at`);
|
||||||
19
db/migrations/002_add_students.sql
Normal file
19
db/migrations/002_add_students.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- idempotent script to add students table
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
student_number VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
middle_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
date_of_birth DATE NOT NULL,
|
||||||
|
gender VARCHAR(10) NOT NULL CHECK (gender IN ('Male', 'Female')),
|
||||||
|
admission_date DATE NOT NULL,
|
||||||
|
student_status VARCHAR(20) NOT NULL DEFAULT 'Active',
|
||||||
|
subledger_id CHAR(36) UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_subledger
|
||||||
|
FOREIGN KEY(subledger_id)
|
||||||
|
REFERENCES subledgers(id)
|
||||||
|
);
|
||||||
53
db/migrations/003_add_required_accounts.php
Normal file
53
db/migrations/003_add_required_accounts.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// Migration: Add Required Chart of Accounts
|
||||||
|
// This script ensures that the essential accounts for the revenue cycle exist.
|
||||||
|
|
||||||
|
if (!isset($pdo)) {
|
||||||
|
// This script should be run by migrate.php, which provides the $pdo variable.
|
||||||
|
echo "This script must be run from migrate.php\n";
|
||||||
|
return "Error: Not executed from migrate.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
function account_exists($pdo, $account_code) {
|
||||||
|
$stmt = $pdo->prepare("SELECT 1 FROM accounts WHERE account_code = ?");
|
||||||
|
$stmt->execute([$account_code]);
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insert_account($pdo, $id, $code, $name, $type, $parent_id = null, $is_control = false) {
|
||||||
|
if (account_exists($pdo, $code)) {
|
||||||
|
return "Skipped: Account '{$code}' already exists.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO accounts (id, account_code, account_name, account_type, parent_account_id, is_control_account) " .
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$id, $code, $name, $type, $parent_id, $is_control ? 1 : 0]);
|
||||||
|
return "Created: Account '{$code}' - {$name}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
// 1. Asset Accounts
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '1000-CASH', 'Cash on Hand', 'Asset');
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '1010-BANK', 'Bank Account', 'Asset');
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '1015-MOMO', 'Mobile Money Account', 'Asset');
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '1200-AR-STUDENTS', 'Accounts Receivable - Students', 'Asset', null, true);
|
||||||
|
|
||||||
|
// 2. Revenue Accounts
|
||||||
|
$revenue_parent_id = uuid_v4();
|
||||||
|
$messages[] = insert_account($pdo, $revenue_parent_id, '4000', 'School Fees Revenue', 'Revenue');
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4100', 'Tuition Fees - Nursery/KG', 'Revenue', $revenue_parent_id);
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4200', 'Tuition Fees - Primary', 'Revenue', $revenue_parent_id);
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4300', 'Tuition Fees - JHS', 'Revenue', $revenue_parent_id);
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4400', 'Boarding Fees', 'Revenue', $revenue_parent_id);
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4500', 'Transport Fees', 'Revenue', $revenue_parent_id);
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4600', 'PTA & Other Fees', 'Revenue', $revenue_parent_id);
|
||||||
|
|
||||||
|
// 3. Contra-Revenue Accounts
|
||||||
|
// Using 'Revenue' type as it's a contra-revenue account.
|
||||||
|
$messages[] = insert_account($pdo, uuid_v4(), '4900', 'Scholarships & Discounts', 'Revenue');
|
||||||
|
|
||||||
|
return implode("\n", array_filter($messages));
|
||||||
|
|
||||||
20
db/migrations/004_add_fee_structures.sql
Normal file
20
db/migrations/004_add_fee_structures.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS fee_structures (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
academic_year VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft', 'Active', 'Archived')),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fee_structure_lines (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
fee_structure_id CHAR(36) NOT NULL,
|
||||||
|
description VARCHAR(255) NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
revenue_account_id CHAR(36) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (fee_structure_id) REFERENCES fee_structures(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (revenue_account_id) REFERENCES accounts(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
26
db/migrations/005_add_invoices.sql
Normal file
26
db/migrations/005_add_invoices.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
student_id CHAR(36) NOT NULL,
|
||||||
|
invoice_number VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
invoice_date DATE NOT NULL,
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
total_amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft', 'Posted', 'Paid', 'Void')),
|
||||||
|
journal_entry_id CHAR(36) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (student_id) REFERENCES students(id),
|
||||||
|
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
invoice_id CHAR(36) NOT NULL,
|
||||||
|
description VARCHAR(255) NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
revenue_account_id CHAR(36) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (revenue_account_id) REFERENCES accounts(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
24
db/migrations/006_add_payments.sql
Normal file
24
db/migrations/006_add_payments.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
student_id CHAR(36) NOT NULL,
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
amount_received DECIMAL(10, 2) NOT NULL,
|
||||||
|
payment_method VARCHAR(50) NOT NULL CHECK (payment_method IN ('Cash', 'Bank Transfer', 'Mobile Money', 'Other')),
|
||||||
|
reference_number VARCHAR(255) NULL,
|
||||||
|
journal_entry_id CHAR(36) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (student_id) REFERENCES students(id),
|
||||||
|
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_lines (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
payment_id CHAR(36) NOT NULL,
|
||||||
|
invoice_id CHAR(36) NOT NULL,
|
||||||
|
amount_applied DECIMAL(10, 2) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
39
db/migrations/007_add_expense_accounts.php
Normal file
39
db/migrations/007_add_expense_accounts.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../db/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$accounts = [
|
||||||
|
// Expense Accounts
|
||||||
|
['5000', 'Operating Expenses', 'EXPENSE', null],
|
||||||
|
['5100', 'Salaries & Wages Expense', 'EXPENSE', '5000'],
|
||||||
|
['5110', 'Employer SSNIT Expense', 'EXPENSE', '5000'],
|
||||||
|
['5200', 'Utilities Expense', 'EXPENSE', '5000'],
|
||||||
|
['5300', 'Teaching Materials Expense', 'EXPENSE', '5000'],
|
||||||
|
['5400', 'Maintenance & Repairs Expense', 'EXPENSE', '5000'],
|
||||||
|
['5500', 'Transport Expense', 'EXPENSE', '5000'],
|
||||||
|
['5600', 'Feeding Expense', 'EXPENSE', '5000'],
|
||||||
|
['5700', 'Administrative Expenses', 'EXPENSE', '5000'],
|
||||||
|
['5800', 'Professional Fees Expense', 'EXPENSE', '5000'],
|
||||||
|
|
||||||
|
// Liability Accounts
|
||||||
|
['2100-AP', 'Accounts Payable', 'LIABILITY', null],
|
||||||
|
['2200-SALARIES', 'Salaries Payable', 'LIABILITY', null],
|
||||||
|
['2210-SSNIT', 'SSNIT Payable', 'LIABILITY', null],
|
||||||
|
['2220-TAX', 'Income Tax (PAYE) Payable', 'LIABILITY', null]
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT IGNORE INTO accounts (account_code, account_name, account_type, parent_account_code) VALUES (?, ?, ?, ?)");
|
||||||
|
|
||||||
|
foreach ($accounts as $account) {
|
||||||
|
$stmt->execute($account);
|
||||||
|
echo "Inserted account: {$account[0]} - {$account[1]}" . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Migration 007 completed successfully." . PHP_EOL;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("Migration 007 failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
40
db/migrations/008_add_vendors_and_bills.sql
Normal file
40
db/migrations/008_add_vendors_and_bills.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- Vendor is a subledger, just like a student
|
||||||
|
CREATE TABLE IF NOT EXISTS vendors (
|
||||||
|
vendor_id VARCHAR(36) PRIMARY KEY,
|
||||||
|
subledger_id VARCHAR(36) NOT NULL,
|
||||||
|
vendor_name VARCHAR(255) NOT NULL,
|
||||||
|
contact_person VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
address TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (subledger_id) REFERENCES subledgers(subledger_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A bill is an operational document from a vendor
|
||||||
|
CREATE TABLE IF NOT EXISTS bills (
|
||||||
|
bill_id VARCHAR(36) PRIMARY KEY,
|
||||||
|
vendor_id VARCHAR(36) NOT NULL,
|
||||||
|
journal_entry_id VARCHAR(36),
|
||||||
|
bill_date DATE NOT NULL,
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
total_amount DECIMAL(18, 2) NOT NULL,
|
||||||
|
status ENUM('DRAFT', 'SUBMITTED', 'PAID', 'VOID') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
|
||||||
|
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(journal_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lines of a bill, detailing the expenses
|
||||||
|
CREATE TABLE IF NOT EXISTS bill_lines (
|
||||||
|
bill_line_id VARCHAR(36) PRIMARY KEY,
|
||||||
|
bill_id VARCHAR(36) NOT NULL,
|
||||||
|
expense_account_code VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
amount DECIMAL(18, 2) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (bill_id) REFERENCES bills(bill_id),
|
||||||
|
FOREIGN KEY (expense_account_code) REFERENCES accounts(account_code)
|
||||||
|
);
|
||||||
3
db/migrations/009_add_payment_reference_type.sql
Normal file
3
db/migrations/009_add_payment_reference_type.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE payments ADD COLUMN reference_type ENUM('INVOICE', 'BILL') NOT NULL DEFAULT 'INVOICE';
|
||||||
|
ALTER TABLE payments ADD COLUMN vendor_id VARCHAR(36) DEFAULT NULL;
|
||||||
|
ALTER TABLE payments MODIFY COLUMN student_id VARCHAR(36) DEFAULT NULL;
|
||||||
10
db/migrations/010_add_accounting_periods.sql
Normal file
10
db/migrations/010_add_accounting_periods.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS accounting_periods (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
period_name VARCHAR(50) NOT NULL COMMENT 'e.g., January 2026',
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
status ENUM('open', 'closed') NOT NULL DEFAULT 'open',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY (start_date, end_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
10
db/migrations/011_add_audit_log.sql
Normal file
10
db/migrations/011_add_audit_log.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user VARCHAR(255) NOT NULL,
|
||||||
|
action VARCHAR(255) NOT NULL,
|
||||||
|
details JSON,
|
||||||
|
INDEX (timestamp),
|
||||||
|
INDEX (user),
|
||||||
|
INDEX (action)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
11
includes/audit_helpers.php
Normal file
11
includes/audit_helpers.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
function log_audit_trail(PDO $pdo, string $action, array $details, string $user = 'system') {
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO audit_log (user, action, details) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$user, $action, json_encode($details)]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// For now, we will log the error to the system's error log.
|
||||||
|
// In a production environment, this should be handled more robustly.
|
||||||
|
error_log('Failed to log audit trail: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
61
includes/journal_helpers.php
Normal file
61
includes/journal_helpers.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/uuid.php';
|
||||||
|
require_once __DIR__ . '/audit_helpers.php';
|
||||||
|
|
||||||
|
function post_journal_entry(PDO $pdo, array $payload): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (empty($payload['entry_date']) || empty($payload['description']) || empty($payload['lines'])) {
|
||||||
|
return ['error' => 'Missing required fields for journal entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accounting Period Control ---
|
||||||
|
$stmt_period_check = $pdo->prepare(
|
||||||
|
"SELECT COUNT(*) FROM accounting_periods WHERE status = 'closed' AND :entry_date BETWEEN start_date AND end_date"
|
||||||
|
);
|
||||||
|
$stmt_period_check->execute([':entry_date' => $payload['entry_date']]);
|
||||||
|
if ($stmt_period_check->fetchColumn() > 0) {
|
||||||
|
return ['error' => 'Cannot post transaction to a closed accounting period.'];
|
||||||
|
}
|
||||||
|
// --- End Control ---
|
||||||
|
|
||||||
|
$total_debits = 0;
|
||||||
|
$total_credits = 0;
|
||||||
|
foreach ($payload['lines'] as $line) {
|
||||||
|
$debit = isset($line['debit']) ? $line['debit'] : 0;
|
||||||
|
$credit = isset($line['credit']) ? $line['credit'] : 0;
|
||||||
|
$total_debits += $debit;
|
||||||
|
$total_credits += $credit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round($total_debits, 2) !== round($total_credits, 2) || $total_debits === 0) {
|
||||||
|
return ['error' => 'Debits and credits must balance and not be zero.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$journal_entry_id = uuid_v4();
|
||||||
|
$stmt_je = $pdo->prepare("INSERT INTO journal_entries (id, entry_date, description, status) VALUES (?, ?, ?, 'posted')");
|
||||||
|
$stmt_je->execute([$journal_entry_id, $payload['entry_date'], $payload['description']]);
|
||||||
|
|
||||||
|
$stmt_lines = $pdo->prepare("INSERT INTO journal_lines (id, journal_entry_id, account_code, debit_amount, credit_amount) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt_subledger = $pdo->prepare("INSERT INTO journal_line_subledgers (journal_line_id, subledger_id) VALUES (?, ?)");
|
||||||
|
|
||||||
|
foreach ($payload['lines'] as $line) {
|
||||||
|
$journal_line_id = uuid_v4();
|
||||||
|
$debit = $line['debit'] ?? 0;
|
||||||
|
$credit = $line['credit'] ?? 0;
|
||||||
|
$stmt_lines->execute([$journal_line_id, $journal_entry_id, $line['account_code'], $debit, $credit]);
|
||||||
|
|
||||||
|
if (!empty($line['subledger_id'])) {
|
||||||
|
$stmt_subledger->execute([$journal_line_id, $line['subledger_id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
log_audit_trail($pdo, 'post_journal_entry', ['journal_entry_id' => $journal_entry_id, 'description' => $payload['description']]);
|
||||||
|
|
||||||
|
return ['success' => true, 'journal_entry_id' => $journal_entry_id];
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ['error' => 'Database error in post_journal_entry: ' . $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
includes/uuid.php
Normal file
24
includes/uuid.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// includes/uuid.php
|
||||||
|
|
||||||
|
function gen_uuid() {
|
||||||
|
return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
// 32 bits for "time_low"
|
||||||
|
mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ),
|
||||||
|
|
||||||
|
// 16 bits for "time_mid"
|
||||||
|
mt_rand( 0, 0xffff ),
|
||||||
|
|
||||||
|
// 16 bits for "time_hi_and_version",
|
||||||
|
// four most significant bits holds version number 4
|
||||||
|
mt_rand( 0, 0x0fff ) | 0x4000,
|
||||||
|
|
||||||
|
// 16 bits, 8 bits for "clk_seq_hi_res",
|
||||||
|
// 8 bits for "clk_seq_low",
|
||||||
|
// two most significant bits holds zero and one for variant DCE1.1
|
||||||
|
mt_rand( 0, 0x3fff ) | 0x8000,
|
||||||
|
|
||||||
|
// 48 bits for "node"
|
||||||
|
mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff )
|
||||||
|
);
|
||||||
|
}
|
||||||
160
index.php
160
index.php
@ -1,150 +1,20 @@
|
|||||||
<?php
|
<!DOCTYPE html>
|
||||||
declare(strict_types=1);
|
|
||||||
@ini_set('display_errors', '1');
|
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
?>
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>New Style</title>
|
<title>School Accounting Dashboard</title>
|
||||||
<?php
|
<link rel="stylesheet" href="dashboard/assets/css/style.css">
|
||||||
// Read project preview data from environment
|
<!-- React and Babel for JSX transpilation -->
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
|
||||||
?>
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
<?php if ($projectDescription): ?>
|
|
||||||
<!-- Meta description -->
|
|
||||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
|
||||||
<!-- Open Graph meta tags -->
|
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<!-- Twitter meta tags -->
|
|
||||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($projectImageUrl): ?>
|
|
||||||
<!-- Open Graph image -->
|
|
||||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<!-- Twitter image -->
|
|
||||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% { background-position: 0% 0%; }
|
|
||||||
100% { background-position: 100% 100%; }
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.loader {
|
|
||||||
margin: 1.25rem auto 1.25rem;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px; height: 1px;
|
|
||||||
padding: 0; margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<div id="root"></div>
|
||||||
<div class="card">
|
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<!-- React Application -->
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<script type="text/babel" src="dashboard/src/App.js"></script>
|
||||||
<span class="sr-only">Loading…</span>
|
<script type="text/babel" src="dashboard/src/index.js"></script>
|
||||||
</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>
|
|
||||||
</main>
|
|
||||||
<footer>
|
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user