Files
2026-02-10 22:11:06 -05:00

349 lines
13 KiB
PHP

<?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.');
}
}