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