add projet

This commit is contained in:
root
2026-03-05 12:29:37 -05:00
parent 8d1eef8ba8
commit 23b7db1107
9109 changed files with 1106501 additions and 73 deletions
+429
View File
@@ -0,0 +1,429 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\Expense;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class ExpenseController extends BaseApiController
{
protected Expense $expense;
protected User $user;
protected Configuration $config;
protected ?string $schoolYear;
protected ?string $semester;
protected array $retailors;
public function __construct()
{
parent::__construct();
$this->expense = model(Expense::class);
$this->user = model(User::class);
$this->config = model(Configuration::class);
$this->schoolYear = $this->config->getConfig('school_year');
$this->semester = $this->config->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'
];
}
public function index()
{
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20)));
$status = $this->request->getGet('status');
$category = $this->request->getGet('category');
$query = $this->expense
->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');
if ($this->schoolYear) {
$query->where('expenses.school_year', $this->schoolYear);
}
if ($this->semester) {
$query->where('expenses.semester', $this->semester);
}
if ($status) {
$query->where('expenses.status', $status);
}
if ($category) {
$query->where('expenses.category', $category);
}
$expenses = $query->orderBy('expenses.created_at', 'DESC')->findAll();
// Enrich each row with a URL that goes through Files::receipt($name)
$expenses = array_map(function ($row) {
$name = $row['receipt_path'] ?? null;
$row['receipt_url'] = $this->receiptUrl($name);
return $row;
}, $expenses);
// Apply pagination manually for array results
$total = count($expenses);
$offset = ($page - 1) * $perPage;
$paginatedExpenses = array_slice($expenses, $offset, $perPage);
$result = [
'data' => $paginatedExpenses,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
return $this->success($result, 'Expenses retrieved successfully');
}
public function show($id = null)
{
$expense = $this->expense
->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')
->where('expenses.id', $id)
->first();
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
// Add receipt URL
$expense['receipt_url'] = $this->receiptUrl($expense['receipt_path'] ?? null);
return $this->success($expense, 'Expense retrieved successfully');
}
/**
* Get metadata for creating an expense (users list and retailors)
*/
public function createMetadata()
{
// Limit users to non-teacher/non-parent
$users = $this->user
->select('users.id, users.firstname, users.lastname')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->whereNotIn('roles.name', ['teacher', 'parent'])
->distinct()
->findAll();
return $this->success([
'users' => $users,
'retailors' => $this->retailors,
], 'Metadata retrieved successfully');
}
public function create()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'category' => 'required|in:Expense,Purchase,Reimbursement',
'amount' => 'required|numeric|gt:0',
'purchased_by' => 'required',
'retailor' => 'nullable|max:255',
'date_of_purchase'=> 'nullable|date',
];
// Handle file upload validation
if ($this->laravelRequest->hasFile('receipt')) {
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
} else {
$rules['receipt'] = 'required|file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
}
// Use Laravel Validator directly for file validation
$validator = Validator::make($this->laravelRequest->all(), $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
// Parse "purchased_by" as "7|John Doe" if provided in that format
$purchasedById = $user->id;
if (isset($data['purchased_by'])) {
$purchasedInfo = (string) $data['purchased_by'];
if (strpos($purchasedInfo, '|') !== false) {
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
$purchasedById = (int) $purchasedById;
} else {
$purchasedById = (int) $purchasedInfo;
}
}
// Handle file upload
$receiptName = null;
if ($this->laravelRequest->hasFile('receipt')) {
$file = $this->laravelRequest->file('receipt');
if ($file->isValid()) {
$targetDir = storage_path('app/uploads/receipts');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$extension = $file->getClientOriginalExtension();
$newName = uniqid('', true) . '.' . $extension;
$file->move($targetDir, $newName);
$receiptName = $newName;
}
}
$schoolYear = $this->schoolYear ?: date('Y');
$semester = $this->semester ?: 'Fall';
$expenseData = [
'category' => $data['category'],
'amount' => $data['amount'],
'receipt_path' => $receiptName,
'description' => $data['description'] ?? null,
'retailor' => !empty($data['retailor']) ? trim($data['retailor']) : null,
'date_of_purchase' => $data['date_of_purchase'] ?? null,
'purchased_by' => $purchasedById,
'added_by' => $user->id,
'school_year' => $schoolYear,
'semester' => $semester,
'status' => 'pending',
];
try {
$expense = $this->expense->create($expenseData);
$expense['receipt_url'] = $this->receiptUrl($receiptName);
return $this->respondCreated($expense, 'Expense created successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense creation error: ' . $e->getMessage());
return $this->respondError('Failed to create expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get metadata for editing an expense
*/
public function editMetadata($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
// Get users list
$users = $this->user
->select('users.id, users.firstname, users.lastname')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->whereNotIn('roles.name', ['teacher', 'parent'])
->distinct()
->findAll();
$expense['receipt_url'] = $expense['receipt_path'] ? $this->receiptUrl(basename($expense['receipt_path'])) : null;
return $this->success([
'expense' => $expense,
'users' => $users,
'retailors' => $this->retailors,
], 'Metadata retrieved successfully');
}
public function update($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
if (empty($data)) {
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
}
$rules = [
'category' => 'required|in:Expense,Purchase,Reimbursement',
'amount' => 'required|numeric|gt:0',
'purchased_by' => 'required',
'retailor' => 'nullable|max:255',
'date_of_purchase'=> 'nullable|date',
];
// Optional new receipt validation (only if provided)
if ($this->laravelRequest->hasFile('receipt')) {
$rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf';
}
// Use Laravel Validator directly for file validation
$validator = Validator::make($this->laravelRequest->all(), $rules);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
// Parse "purchased_by" as "id|Name" if provided in that format
$purchasedById = $expense['purchased_by'];
if (isset($data['purchased_by'])) {
$purchasedInfo = (string) $data['purchased_by'];
if (strpos($purchasedInfo, '|') !== false) {
[$purchasedById] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
$purchasedById = (int) $purchasedById;
} else {
$purchasedById = (int) $purchasedInfo;
}
}
// Keep old receipt unless replaced or removed
$receiptName = $expense['receipt_path'];
if ($this->laravelRequest->hasFile('receipt')) {
$file = $this->laravelRequest->file('receipt');
if ($file->isValid()) {
$targetDir = storage_path('app/uploads/receipts');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$extension = $file->getClientOriginalExtension();
$newName = uniqid('', true) . '.' . $extension;
$file->move($targetDir, $newName);
$receiptName = $newName;
}
}
if (isset($data['remove_receipt']) && $data['remove_receipt'] === '1') {
$receiptName = null;
}
$updateData = [
'category' => $data['category'],
'amount' => $data['amount'],
'description' => $data['description'] ?? $expense['description'],
'retailor' => isset($data['retailor']) ? (trim($data['retailor']) ?: null) : $expense['retailor'],
'date_of_purchase'=> $data['date_of_purchase'] ?? $expense['date_of_purchase'],
'purchased_by' => $purchasedById,
'receipt_path' => $receiptName,
'updated_by' => $user->id,
];
try {
$this->expense->update($id, $updateData);
$updatedExpense = $this->expense->find($id);
$updatedExpense['receipt_url'] = $this->receiptUrl($receiptName);
return $this->success($updatedExpense, 'Expense updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense update error: ' . $e->getMessage());
return $this->respondError('Failed to update expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function updateStatus()
{
$user = $this->getCurrentUser();
if (!$user) {
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
}
$data = $this->payloadData();
$id = isset($data['id']) ? (int)$data['id'] : null;
$status = $data['status'] ?? null;
$reason = $data['reason'] ?? '';
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
log_message('error', 'Invalid status or ID');
return $this->respondError('Invalid data', Response::HTTP_BAD_REQUEST);
}
$expense = $this->expense->find($id);
if (!$expense) {
log_message('error', 'Expense not found for ID ' . $id);
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
try {
$this->expense->update($id, [
'status' => $status,
'status_reason' => $reason,
'approved_by' => $user->id,
'updated_by' => $user->id
]);
$updatedExpense = $this->expense->find($id);
return $this->success($updatedExpense, 'Expense status updated successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense update failed for ID ' . $id . ': ' . $e->getMessage());
return $this->respondError('Update failed', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function delete($id = null)
{
$expense = $this->expense->find($id);
if (!$expense) {
return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND);
}
try {
$expense->delete();
return $this->success(null, 'Expense deleted successfully');
} catch (\Throwable $e) {
log_message('error', 'Expense delete error: ' . $e->getMessage());
return $this->respondError('Failed to delete expense', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* 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;
}
// Using API route pattern - ensure route exists in routes/api.php
// Example: Route::get('files/receipt/{name}', [FilesController::class, 'receipt']);
return url('/api/v1/files/receipt/' . $filename);
}
}