Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
89
README.md
89
README.md
@ -1,89 +0,0 @@
|
|||||||
# 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.*
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
<?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);
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
<?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);
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<?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);
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<?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()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
<?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()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
<?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()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
<?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()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
<?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()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@ -1,318 +0,0 @@
|
|||||||
:root {
|
|
||||||
--primary-color: #4F46E5;
|
|
||||||
--sidebar-bg: #111827;
|
|
||||||
--sidebar-text: #9CA3AF;
|
|
||||||
--sidebar-active-bg: #374151;
|
|
||||||
--sidebar-active-text: #FFFFFF;
|
|
||||||
--main-bg: #F9FAFB;
|
|
||||||
--text-primary: #1F2937;
|
|
||||||
--text-secondary: #6B7280;
|
|
||||||
--card-bg: #FFFFFF;
|
|
||||||
--border-color: #E5E7EB;
|
|
||||||
--green-light: #ECFDF5;
|
|
||||||
--green-dark: #065F46;
|
|
||||||
--red-light: #FEF2F2;
|
|
||||||
--red-dark: #991B1B;
|
|
||||||
--blue-light: #EFF6FF;
|
|
||||||
--blue-dark: #1E40AF;
|
|
||||||
--yellow-light: #FFFBEB;
|
|
||||||
--yellow-dark: #92400E;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background-color: var(--main-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 260px;
|
|
||||||
background-color: var(--sidebar-bg);
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logo {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-school-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-school-name span:first-child {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--sidebar-active-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-school-name span:last-child {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background-color: #1F2937;
|
|
||||||
color: var(--sidebar-active-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
background-color: var(--sidebar-active-bg);
|
|
||||||
color: var(--sidebar-active-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 24px 32px;
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-profile {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info span:first-child {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info span:last-child {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content {
|
|
||||||
padding: 32px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-header {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-header h1 {
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-header p {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-title {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-icon.blue { background-color: var(--blue-light); color: var(--blue-dark); }
|
|
||||||
.stat-card-icon.green { background-color: var(--green-light); color: var(--green-dark); }
|
|
||||||
.stat-card-icon.red { background-color: var(--red-light); color: var(--red-dark); }
|
|
||||||
|
|
||||||
.stat-card-value {
|
|
||||||
font-size: 2.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-footer {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-footer .increase { color: var(--green-dark); }
|
|
||||||
.stat-card-footer .decrease { color: var(--red-dark); }
|
|
||||||
|
|
||||||
.charts-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card-header h3 {
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card-header p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-placeholder {
|
|
||||||
height: 250px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card h3 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Mother Providencia International School</title>
|
|
||||||
<link rel="stylesheet" href="assets/css/style.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<script src="https://unpkg.com/feather-icons"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<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>
|
|
||||||
<script type="text/babel" src="src/App.js"></script>
|
|
||||||
<script type="text/babel" src="src/index.js"></script>
|
|
||||||
<script>
|
|
||||||
feather.replace();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
|
|
||||||
const App = () => {
|
|
||||||
React.useEffect(() => {
|
|
||||||
feather.replace();
|
|
||||||
|
|
||||||
// Mock Chart for Income vs Expenses
|
|
||||||
const incomeExpenseCtx = document.getElementById('incomeExpenseChart');
|
|
||||||
if (incomeExpenseCtx) {
|
|
||||||
new Chart(incomeExpenseCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb'],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Income',
|
|
||||||
data: [120000, 130000, 140000, 135000, 150000, 160000],
|
|
||||||
borderColor: '#10B981',
|
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}, {
|
|
||||||
label: 'Expenses',
|
|
||||||
data: [90000, 95000, 105000, 100000, 110000, 115000],
|
|
||||||
borderColor: '#EF4444',
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: function(value) {
|
|
||||||
return '$' + value / 1000 + 'k';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock Chart for Fee Collection
|
|
||||||
const feeCollectionCtx = document.getElementById('feeCollectionChart');
|
|
||||||
if (feeCollectionCtx) {
|
|
||||||
new Chart(feeCollectionCtx, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: ['Collected', 'Pending', 'Overdue'],
|
|
||||||
datasets: [{
|
|
||||||
data: [85, 10, 5],
|
|
||||||
backgroundColor: ['#10B981', '#F59E0B', '#EF4444'],
|
|
||||||
borderWidth: 0
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
cutout: '70%',
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = {
|
|
||||||
Overview: [
|
|
||||||
{ name: 'Dashboard', icon: 'grid', active: true },
|
|
||||||
],
|
|
||||||
Academic: [
|
|
||||||
{ name: 'Students', icon: 'users' },
|
|
||||||
{ name: 'Teachers', icon: 'users' },
|
|
||||||
{ name: 'Classes', icon: 'book' },
|
|
||||||
{ name: 'Timetable', icon: 'calendar' },
|
|
||||||
{ name: 'Attendance', icon: 'check-square' },
|
|
||||||
{ name: 'Examinations', icon: 'file-text' },
|
|
||||||
],
|
|
||||||
Finance: [
|
|
||||||
{ name: 'Chart of Accounts', icon: 'file' },
|
|
||||||
{ name: 'Journal Entries', icon: 'book-open' },
|
|
||||||
{ name: 'Invoices (AR)', icon: 'dollar-sign' },
|
|
||||||
{ name: 'Payments', icon: 'trending-up' },
|
|
||||||
{ name: 'Bills (AP)', icon: 'file-minus' },
|
|
||||||
{ name: 'Vendors', icon: 'briefcase' },
|
|
||||||
{ name: 'Inventory', icon: 'archive' },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ title: 'Total Students', value: '487', change: '+12 this month', changeType: 'increase', icon: 'users', iconBg: 'blue' },
|
|
||||||
{ title: 'Total Teachers', value: '32', change: '2 on leave', icon: 'users', iconBg: 'blue' },
|
|
||||||
{ title: 'Total Revenue', value: 'GH₵1,085,500.00', change: '+8.2% from last year', changeType: 'increase', icon: 'dollar-sign', iconBg: 'green' },
|
|
||||||
{ title: 'Pending Fees', value: 'GH₵85,000.00', change: '12 invoices overdue', changeType: 'decrease', icon: 'alert-triangle', iconBg: 'red' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Sidebar = () => (
|
|
||||||
<div className="sidebar">
|
|
||||||
<div className="sidebar-header">
|
|
||||||
<div className="sidebar-logo">
|
|
||||||
<i data-feather="award"></i>
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-school-name">
|
|
||||||
<span>Mother Providencia</span>
|
|
||||||
<span>International School</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
{Object.entries(navItems).map(([section, items]) => (
|
|
||||||
<div className="nav-section" key={section}>
|
|
||||||
<div className="nav-section-title">{section}</div>
|
|
||||||
{items.map(item => (
|
|
||||||
<a href="#" className={`nav-item ${item.active ? 'active' : ''}`} key={item.name}>
|
|
||||||
<i data-feather={item.icon}></i>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div style={{ marginTop: 'auto' }}>
|
|
||||||
<a href="#" className="nav-item">
|
|
||||||
<i data-feather="settings"></i>
|
|
||||||
<span>Settings</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" className="nav-item">
|
|
||||||
<i data-feather="log-out"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Header = () => (
|
|
||||||
<header className="header">
|
|
||||||
<div className="search-bar">
|
|
||||||
<i data-feather="search" style={{ color: 'var(--text-secondary)' }}></i>
|
|
||||||
<input type="text" placeholder="Search students, invoices, accounts..." />
|
|
||||||
</div>
|
|
||||||
<div className="header-actions">
|
|
||||||
<button className="admin-btn" style={{background: '#f8d7da', border: 'none', padding: '5px 10px', borderRadius: '5px'}}>admin</button>
|
|
||||||
<span>Switch Role</span>
|
|
||||||
<i data-feather="bell"></i>
|
|
||||||
<div className="user-profile">
|
|
||||||
<div className="user-avatar">JA</div>
|
|
||||||
<div className="user-info">
|
|
||||||
<span>John Administrator</span>
|
|
||||||
<span>Admin</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StatCard = ({ stat }) => (
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-card-header">
|
|
||||||
<span className="stat-card-title">{stat.title}</span>
|
|
||||||
<div className={`stat-card-icon ${stat.iconBg}`}>
|
|
||||||
<i data-feather={stat.icon}></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card-value">{stat.value}</div>
|
|
||||||
<div className={`stat-card-footer ${stat.changeType}`}>{stat.change}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dashboard">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="main-content">
|
|
||||||
<Header />
|
|
||||||
<div className="page-content">
|
|
||||||
<div className="welcome-header">
|
|
||||||
<h1>Welcome back, John!</h1>
|
|
||||||
<p>Here's your administrative overview of the school.</p>
|
|
||||||
</div>
|
|
||||||
<div className="stats-grid">
|
|
||||||
{stats.map(stat => <StatCard stat={stat} key={stat.title} />)}
|
|
||||||
</div>
|
|
||||||
<div className="charts-grid">
|
|
||||||
<div className="chart-card">
|
|
||||||
<div className="chart-card-header">
|
|
||||||
<h3>Cash Position</h3>
|
|
||||||
<p>Current balances</p>
|
|
||||||
</div>
|
|
||||||
<div style={{height: '250px', display:'flex', flexDirection: 'column', justifyContent:'center', gap: '20px'}}>
|
|
||||||
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
|
|
||||||
<div style={{padding: '10px', background: 'var(--blue-light)', borderRadius: '8px'}}><i data-feather="briefcase"></i></div>
|
|
||||||
<div>
|
|
||||||
<div>Bank Account</div>
|
|
||||||
<div style={{fontSize: '1.5rem', fontWeight: '600'}}>GH₵650,000.00</div>
|
|
||||||
<div style={{fontSize: '0.8rem', color: 'var(--text-secondary)'}}>Operating + Savings</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
|
|
||||||
<div style={{padding: '10px', background: 'var(--green-light)', borderRadius: '8px'}}><i data-feather="dollar-sign"></i></div>
|
|
||||||
<div>
|
|
||||||
<div>Petty Cash</div>
|
|
||||||
<div style={{fontSize: '1.5rem', fontWeight: '600'}}>GH₵2,500.00</div>
|
|
||||||
<div style={{fontSize: '0.8rem', color: 'var(--text-secondary)'}}>On hand</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{color: 'var(--red-dark)', fontWeight:'500', display:'flex', alignItems:'center', gap: '5px'}}><i data-feather="trending-down"></i>Upcoming payables GH₵31,000.00</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="chart-card">
|
|
||||||
<div className="chart-card-header">
|
|
||||||
<h3>Fee Collection</h3>
|
|
||||||
<p>Current academic year</p>
|
|
||||||
</div>
|
|
||||||
<div className="chart-placeholder">
|
|
||||||
<canvas id="feeCollectionChart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div style={{display: 'flex', justifyContent: 'center', gap: '15px', marginTop: '15px'}}>
|
|
||||||
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#10B981'}}></div>Collected</span>
|
|
||||||
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#F59E0B'}}></div>Pending</span>
|
|
||||||
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#EF4444'}}></div>Overdue</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="chart-card">
|
|
||||||
<div className="chart-card-header">
|
|
||||||
<h3>Income vs Expenses</h3>
|
|
||||||
<p>Last 6 months trend</p>
|
|
||||||
</div>
|
|
||||||
<div className="chart-placeholder">
|
|
||||||
<canvas id="incomeExpenseChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="charts-grid" style={{gridTemplateColumns: '1fr 1fr'}}>
|
|
||||||
<div className="table-card">
|
|
||||||
<h3>Recent Invoices</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Student</th><th>Amount</th><th>Status</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Kwabena Adu</td><td>GH₵1,200.00</td><td>Paid</td></tr>
|
|
||||||
<tr><td>Afiya Owusu</td><td>GH₵1,200.00</td><td>Pending</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="table-card">
|
|
||||||
<h3>Recent Payments</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Student</th><th>Amount</th><th>Date</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Kwabena Adu</td><td>GH₵1,200.00</td><td>2024-02-15</td></tr>
|
|
||||||
<tr><td>Esi Mensah</td><td>GH₵1,000.00</td><td>2024-02-14</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ReactDOM.render(<App />, document.getElementById('root'));
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<?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;
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
-- 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`);
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
);
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
<?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));
|
|
||||||
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
);
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?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()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<?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 )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
158
index.php
158
index.php
@ -1,20 +1,150 @@
|
|||||||
<!DOCTYPE html>
|
<?php
|
||||||
|
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.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>School Accounting Dashboard</title>
|
<title>New Style</title>
|
||||||
<link rel="stylesheet" href="dashboard/assets/css/style.css">
|
<?php
|
||||||
<!-- React and Babel for JSX transpilation -->
|
// Read project preview data from environment
|
||||||
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
|
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||||
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
|
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||||
<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>
|
||||||
<div id="root"></div>
|
<main>
|
||||||
|
<div class="card">
|
||||||
<!-- React Application -->
|
<h1>Analyzing your requirements and generating your website…</h1>
|
||||||
<script type="text/babel" src="dashboard/src/App.js"></script>
|
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||||
<script type="text/babel" src="dashboard/src/index.js"></script>
|
<span class="sr-only">Loading…</span>
|
||||||
|
</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