reconstruction of the project
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user