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