349 lines
13 KiB
PHP
Executable File
349 lines
13 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
use App\Controllers\BaseController;
|
|
use App\Models\ExpenseModel;
|
|
use App\Models\UserModel;
|
|
use App\Models\ConfigurationModel;
|
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
|
|
|
class ExpenseController extends BaseController
|
|
{
|
|
protected $expenseModel;
|
|
protected $userModel;
|
|
protected $configModel;
|
|
protected $schoolYear;
|
|
protected $semester;
|
|
protected $retailors;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->expenseModel = new ExpenseModel();
|
|
$this->userModel = new UserModel();
|
|
$this->configModel = new ConfigurationModel();
|
|
|
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
$this->semester = $this->configModel->getConfig('semester');
|
|
|
|
// Default list of common retailors; adjust as needed
|
|
$this->retailors = [
|
|
'Amazon',
|
|
'Walmart',
|
|
'Costco',
|
|
'BJ\'s',
|
|
'Market Basket',
|
|
'Aldi',
|
|
'Hannaford',
|
|
'Sam\'s Club',
|
|
'HomeGoods',
|
|
'Hostinger',
|
|
'Wicked Cheesy',
|
|
'Shatila',
|
|
'Brothers Pizzeria',
|
|
'Paradise Biryani Pointe',
|
|
'Emad Leiman',
|
|
'Nova Trampoline Park',
|
|
'Lubin\'s Awards',
|
|
'Dollar Tree',
|
|
'Stop & Shop',
|
|
'Dunkin\' Donuts',
|
|
'Giovanni\'s Pizza',
|
|
'Trader Joes'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Return staff users (admins/teachers/etc.) excluding parents/guests.
|
|
*/
|
|
private function staffUsers(): array
|
|
{
|
|
$rows = $this->userModel
|
|
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
|
|
->join('user_roles', 'user_roles.user_id = users.id', 'left')
|
|
->join('roles', 'roles.id = user_roles.role_id', 'left')
|
|
->where('roles.name IS NOT NULL', null, false)
|
|
->findAll();
|
|
|
|
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
|
$staff = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$roleName = strtolower((string) ($row['role_name'] ?? ''));
|
|
$id = (int) ($row['id'] ?? 0);
|
|
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
|
continue;
|
|
}
|
|
if (!isset($staff[$id])) {
|
|
$staff[$id] = [
|
|
'id' => $id,
|
|
'firstname' => $row['firstname'] ?? '',
|
|
'lastname' => $row['lastname'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
uasort($staff, static function ($a, $b) {
|
|
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
|
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
|
return strcasecmp($nameA, $nameB);
|
|
});
|
|
|
|
return array_values($staff);
|
|
}
|
|
|
|
public function index()
|
|
{
|
|
$expenses = $this->expenseModel
|
|
->select("
|
|
expenses.*,
|
|
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
|
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
|
")
|
|
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
|
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
|
->orderBy('expenses.created_at', 'DESC')
|
|
->findAll();
|
|
|
|
// Enrich each row with a URL that goes through Files::receipt($name)
|
|
// We store only the filename in 'receipt_path' (e.g., "1759...f62.png")
|
|
$expenses = array_map(function ($row) {
|
|
$name = $row['receipt_path'] ?? null;
|
|
$row['receipt_url'] = $this->receiptUrl($name);
|
|
return $row;
|
|
}, $expenses);
|
|
|
|
return view('expenses/index', ['expenses' => $expenses]);
|
|
}
|
|
|
|
public function create()
|
|
{
|
|
$users = $this->staffUsers();
|
|
|
|
return view('expenses/create', [
|
|
'users' => $users,
|
|
'retailors' => $this->retailors,
|
|
]);
|
|
}
|
|
|
|
public function store()
|
|
{
|
|
$rules = [
|
|
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
|
'amount' => 'required|decimal|greater_than[0]',
|
|
// Frontend sends purchased_by as "id|Full Name"
|
|
'purchased_by' => 'required',
|
|
// Optional extra fields
|
|
'retailor' => 'permit_empty|max_length[255]',
|
|
'date_of_purchase' => 'permit_empty',
|
|
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
|
'receipt' => 'uploaded[receipt]'
|
|
. '|max_size[receipt,2048]'
|
|
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
|
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
|
];
|
|
|
|
$messages = [
|
|
'receipt' => [
|
|
'uploaded' => 'Receipt file is required.',
|
|
'max_size' => 'Maximum file size is 2MB.',
|
|
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
|
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
|
]
|
|
];
|
|
|
|
if (!$this->validate($rules, $messages)) {
|
|
return redirect()->back()->withInput()->with('error', $this->validator->listErrors());
|
|
}
|
|
|
|
// Safe values
|
|
$category = (string) $this->request->getPost('category');
|
|
$amount = (string) $this->request->getPost('amount');
|
|
$description = (string) $this->request->getPost('description');
|
|
$retailor = trim((string) $this->request->getPost('retailor'));
|
|
$datePurchase = (string) $this->request->getPost('date_of_purchase');
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
$isDonation = ($category === 'Donation');
|
|
|
|
// Parse "purchased_by" as "7|John Doe"
|
|
$purchasedInfo = (string) $this->request->getPost('purchased_by');
|
|
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
|
$purchasedById = (int) $purchasedById;
|
|
|
|
// School context
|
|
$schoolYear = $this->schoolYear ?: date('Y');
|
|
$semester = $this->semester ?: 'Fall';
|
|
|
|
// Handle upload: store under writable/uploads/receipts and save only the filename
|
|
$receiptName = null;
|
|
$file = $this->request->getFile('receipt');
|
|
if ($file && $file->isValid() && !$file->hasMoved()) {
|
|
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
|
$receiptName = basename($stored);
|
|
}
|
|
|
|
$status = $isDonation ? 'approved' : 'pending';
|
|
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
|
|
|
$this->expenseModel->insert([
|
|
'category' => $category,
|
|
'amount' => $amount,
|
|
'receipt_path' => $receiptName, // filename only
|
|
'description' => $description,
|
|
'retailor' => ($retailor !== '') ? $retailor : null,
|
|
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
|
'purchased_by' => $purchasedById,
|
|
'added_by' => $userId,
|
|
'status' => $status,
|
|
'status_reason'=> $statusReason,
|
|
'approved_by' => $isDonation ? $userId : null,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
]);
|
|
|
|
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
|
}
|
|
|
|
|
|
public function updateStatus()
|
|
{
|
|
$data = $this->request->getJSON(true);
|
|
$id = isset($data['id']) ? (int)$data['id'] : null;
|
|
$status = $data['status'] ?? null;
|
|
$reason = $data['reason'] ?? '';
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
|
|
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
|
|
log_message('error', 'Invalid status or ID');
|
|
return $this->response->setJSON(['error' => 'Invalid data']);
|
|
}
|
|
|
|
$expense = $this->expenseModel->find($id);
|
|
if (!$expense) {
|
|
log_message('error', 'Expense not found for ID ' . $id);
|
|
return $this->response->setJSON(['error' => 'Expense not found']);
|
|
}
|
|
|
|
$success = $this->expenseModel->update($id, [
|
|
'status' => $status,
|
|
'status_reason' => $reason,
|
|
'approved_by' => $userId,
|
|
'updated_by' => $userId
|
|
]);
|
|
|
|
if (!$success) {
|
|
log_message('error', 'Expense update failed for ID ' . $id);
|
|
return $this->response->setJSON(['error' => 'Update failed']);
|
|
}
|
|
|
|
return $this->response->setJSON(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Build a public URL for a receipt filename through Files::receipt($name).
|
|
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
|
|
*/
|
|
private function receiptUrl(?string $filename): ?string
|
|
{
|
|
if (!$filename) {
|
|
return null;
|
|
}
|
|
// Route should be defined as: $routes->get('receipts/(:any)', 'Files::receipt/$1');
|
|
return site_url('receipts/' . $filename);
|
|
}
|
|
|
|
|
|
|
|
public function edit(int $id)
|
|
{
|
|
$expense = $this->expenseModel->find($id);
|
|
if (!$expense) {
|
|
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
|
}
|
|
|
|
// same user list you use in create()
|
|
$users = $this->staffUsers();
|
|
|
|
return view('expenses/edit', [
|
|
'expense' => $expense,
|
|
'users' => $users,
|
|
'retailors' => $this->retailors,
|
|
'receipt_url' => $expense['receipt_path'] ? site_url('receipts/' . basename($expense['receipt_path'])) : null,
|
|
]);
|
|
}
|
|
|
|
public function update(int $id)
|
|
{
|
|
helper(['form']);
|
|
|
|
$expense = $this->expenseModel->find($id);
|
|
if (!$expense) {
|
|
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
|
}
|
|
|
|
// Base rules
|
|
$rules = [
|
|
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
|
'amount' => 'required|decimal|greater_than[0]',
|
|
'purchased_by' => 'required', // still "id|Full Name"
|
|
'retailor' => 'permit_empty|max_length[255]',
|
|
'date_of_purchase' => 'permit_empty',
|
|
];
|
|
|
|
// Optional new receipt validation (only if provided)
|
|
$file = $this->request->getFile('receipt');
|
|
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
|
|
$rules['receipt'] = 'max_size[receipt,2048]'
|
|
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
|
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]';
|
|
}
|
|
|
|
if (!$this->validate($rules)) {
|
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
}
|
|
|
|
// Parse "id|Name"
|
|
[$purchasedById] = array_pad(explode('|', (string) $this->request->getPost('purchased_by'), 2), 2, null);
|
|
$purchasedById = (int) $purchasedById;
|
|
$category = (string) $this->request->getPost('category');
|
|
$isDonation = ($category === 'Donation');
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
|
|
// Keep old receipt unless replaced or removed
|
|
$receiptName = $expense['receipt_path'];
|
|
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
|
$stored = $file->store('receipts');
|
|
$receiptName = basename($stored);
|
|
}
|
|
if ($this->request->getPost('remove_receipt') === '1') {
|
|
$receiptName = null;
|
|
}
|
|
|
|
$updateData = [
|
|
'category' => $category,
|
|
'amount' => (string) $this->request->getPost('amount'),
|
|
'description' => (string) $this->request->getPost('description'),
|
|
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
|
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
|
'purchased_by' => $purchasedById,
|
|
'receipt_path' => $receiptName,
|
|
'updated_by' => $userId,
|
|
];
|
|
|
|
if ($isDonation) {
|
|
$updateData['status'] = 'approved';
|
|
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
|
$updateData['approved_by'] = $userId ?: null;
|
|
$updateData['reimbursement_id'] = null;
|
|
} elseif (($expense['category'] ?? '') === 'Donation') {
|
|
// Moving a donation back to a reimbursable category: clear the marker.
|
|
$updateData['status_reason'] = null;
|
|
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
|
$updateData['status'] = $expense['status'] ?? 'pending';
|
|
}
|
|
|
|
$this->expenseModel->update($id, $updateData);
|
|
|
|
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
|
}
|
|
}
|