diff --git a/app/Http/Controllers/App/BillController.php b/app/Http/Controllers/App/BillController.php new file mode 100644 index 0000000..70d0968 --- /dev/null +++ b/app/Http/Controllers/App/BillController.php @@ -0,0 +1,232 @@ +filled('q')) { + $query->where('name', 'like', '%' . request()->q . '%'); + } + + $bills = $query->orderByDesc('created_at') + ->paginate(config('app.pagination', 15)); + + return ApiResponse::OK->response(['bills' => $bills]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(BillStoreRequest $request) + { + $data = $request->validated(); + + if ($request->hasFile('file')) { + $data['file_path'] = $request->file('file')->store('bills', 'public'); + } + + unset($data['file']); + + $bill = Bill::create($data); + + return ApiResponse::CREATED->response(['bill' => $bill]); + } + + /** + * Display the specified resource. + */ + public function show(Bill $bill) + { + return ApiResponse::OK->response(['bill' => $bill]); + } + + /** + * Update the specified resource in storage. + */ + public function update(BillUpdateRequest $request, Bill $bill) + { + $data = $request->validated(); + + if ($request->hasFile('file')) { + if ($bill->file_path) { + Storage::disk('public')->delete($bill->file_path); + } + $data['file_path'] = $request->file('file')->store('bills', 'public'); + } + + unset($data['file']); + + $bill->update($data); + + return ApiResponse::OK->response(['bill' => $bill->fresh()]); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Bill $bill) + { + if ($bill->file_path) { + Storage::disk('public')->delete($bill->file_path); + } + + $bill->delete(); + + return ApiResponse::OK->response(); + } + + /** + * Alterna el estado de pago de la factura. + */ + public function togglePaid(Bill $bill) + { + $bill->update(['paid' => !$bill->paid]); + + return ApiResponse::OK->response(['bill' => $bill->fresh()]); + } + + /** + * Exporta a Excel las facturas pendientes de pago. + */ + public function export() + { + $bills = Bill::where('paid', false) + ->orderBy('deadline') + ->orderBy('created_at') + ->get(); + + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Facturas por Pagar'); + + $sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial')->setSize(10); + + $styleHeader = [ + 'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + ]; + + $styleData = [ + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D0D0D0']]], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]; + + // Título + $sheet->mergeCells('A1:E1'); + $sheet->setCellValue('A1', 'FACTURAS PENDIENTES DE PAGO'); + $sheet->getStyle('A1')->applyFromArray([ + 'font' => ['bold' => true, 'size' => 14], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + $sheet->mergeCells('A2:E2'); + $sheet->setCellValue('A2', 'Generado el ' . Carbon::now()->format('d/m/Y H:i')); + $sheet->getStyle('A2')->applyFromArray([ + 'font' => ['italic' => true, 'color' => ['rgb' => '666666']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + // Encabezados + $headers = ['A4' => '#', 'B4' => 'NOMBRE', 'C4' => 'COSTO', 'D4' => 'FECHA LÍMITE', 'E4' => 'DÍAS RESTANTES']; + foreach ($headers as $cell => $text) { + $sheet->setCellValue($cell, $text); + } + $sheet->getStyle('A4:E4')->applyFromArray($styleHeader); + $sheet->getRowDimension(4)->setRowHeight(22); + + // Datos + $row = 5; + $total = 0; + foreach ($bills as $i => $bill) { + $deadline = $bill->deadline ? Carbon::parse($bill->deadline) : null; + // Positivo = días que faltan, negativo = días vencida + $daysLeft = $deadline ? (int) Carbon::today()->diffInDays($deadline, false) : null; + + if ($daysLeft === null) { + $daysLabel = '—'; + } elseif ($daysLeft === 0) { + $daysLabel = 'Hoy'; + } elseif ($daysLeft > 0) { + $daysLabel = "+{$daysLeft}"; + } else { + $daysLabel = (string) $daysLeft; // ya incluye el signo negativo + } + + $sheet->setCellValue('A' . $row, $i + 1); + $sheet->setCellValue('B' . $row, $bill->name); + $sheet->setCellValue('C' . $row, (float) $bill->cost); + $sheet->setCellValue('D' . $row, $deadline?->format('d/m/Y') ?? '—'); + $sheet->setCellValue('E' . $row, $daysLabel); + + $sheet->getStyle('C' . $row)->getNumberFormat()->setFormatCode('$#,##0.00'); + $sheet->getStyle('A' . $row . ':E' . $row)->applyFromArray($styleData); + $sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('C' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); + $sheet->getStyle('D' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle('E' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Verde si quedan días, rojo si está vencida + if ($daysLeft !== null && $daysLeft > 0) { + $sheet->getStyle('E' . $row)->getFont()->getColor()->setRGB('1A7A1A'); + } elseif ($daysLeft !== null && $daysLeft < 0) { + $sheet->getStyle('D' . $row . ':E' . $row)->getFont()->getColor()->setRGB('CC0000'); + $sheet->getStyle('D' . $row . ':E' . $row)->getFont()->setBold(true); + } elseif ($daysLeft === 0) { + $sheet->getStyle('E' . $row)->getFont()->getColor()->setRGB('B45309'); + $sheet->getStyle('E' . $row)->getFont()->setBold(true); + } + + $total += (float) $bill->cost; + $row++; + } + + // Total + $sheet->setCellValue('B' . $row, 'TOTAL PENDIENTE'); + $sheet->setCellValue('C' . $row, $total); + $sheet->getStyle('C' . $row)->getNumberFormat()->setFormatCode('$#,##0.00'); + $sheet->getStyle('B' . $row . ':C' . $row)->applyFromArray([ + 'font' => ['bold' => true, 'size' => 11], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FFF2CC']], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT], + ]); + + // Anchos de columna + $sheet->getColumnDimension('A')->setWidth(6); + $sheet->getColumnDimension('B')->setWidth(40); + $sheet->getColumnDimension('C')->setWidth(18); + $sheet->getColumnDimension('D')->setWidth(16); + $sheet->getColumnDimension('E')->setWidth(16); + + // Generar archivo + $fileName = 'Facturas_Pendientes_' . Carbon::now()->format('Ymd_His') . '.xlsx'; + $filePath = storage_path('app/temp/' . $fileName); + if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true); + + (new Xlsx($spreadsheet))->save($filePath); + + return response()->download($filePath, $fileName, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ])->deleteFileAfterSend(true); + } +} diff --git a/app/Http/Requests/App/BillStoreRequest.php b/app/Http/Requests/App/BillStoreRequest.php new file mode 100644 index 0000000..cf4ce3c --- /dev/null +++ b/app/Http/Requests/App/BillStoreRequest.php @@ -0,0 +1,45 @@ + ['required', 'string', 'max:255'], + 'cost' => ['required', 'numeric', 'min:0'], + 'deadline' => ['nullable', 'date'], + 'paid' => ['boolean'], + 'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], + ]; + } + + /** + * Custom validation messages + */ + public function messages(): array + { + return [ + 'name.required' => 'El nombre de la factura es obligatorio.', + 'cost.required' => 'El costo es obligatorio.', + 'cost.numeric' => 'El costo debe ser un número válido.', + 'cost.min' => 'El costo debe ser un valor positivo.', + 'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).', + 'file.max' => 'El archivo no puede superar los 10 MB.', + ]; + } +} diff --git a/app/Http/Requests/App/BillUpdateRequest.php b/app/Http/Requests/App/BillUpdateRequest.php new file mode 100644 index 0000000..d60a88d --- /dev/null +++ b/app/Http/Requests/App/BillUpdateRequest.php @@ -0,0 +1,45 @@ + ['required', 'string', 'max:255'], + 'cost' => ['required', 'numeric', 'min:0'], + 'deadline' => ['nullable', 'date'], + 'paid' => ['boolean'], + 'file' => ['sometimes', 'nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], + ]; + } + + /** + * Custom validation messages + */ + public function messages(): array + { + return [ + 'name.required' => 'El nombre de la factura es obligatorio.', + 'cost.required' => 'El costo es obligatorio.', + 'cost.numeric' => 'El costo debe ser un número válido.', + 'cost.min' => 'El costo debe ser un valor positivo.', + 'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).', + 'file.max' => 'El archivo no puede superar los 10 MB.', + ]; + } +} diff --git a/app/Models/Bill.php b/app/Models/Bill.php new file mode 100644 index 0000000..4e1f6e3 --- /dev/null +++ b/app/Models/Bill.php @@ -0,0 +1,31 @@ + 'decimal:2', + 'paid' => 'boolean', + ]; + + protected $appends = ['file_url']; + + public function getFileUrlAttribute(): ?string + { + if (!$this->file_path) return null; + return asset('storage/' . $this->file_path); + } +} diff --git a/database/migrations/2026_03_21_092446_create_bills_table.php b/database/migrations/2026_03_21_092446_create_bills_table.php new file mode 100644 index 0000000..7c74d1b --- /dev/null +++ b/database/migrations/2026_03_21_092446_create_bills_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->decimal('cost', 12, 2); + $table->string('file_path')->nullable(); + $table->date('deadline')->nullable(); + $table->boolean('paid')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bills'); + } +}; diff --git a/database/migrations/seed/2026_03_21_100151_add_bills_permission.php b/database/migrations/seed/2026_03_21_100151_add_bills_permission.php new file mode 100644 index 0000000..c764d2b --- /dev/null +++ b/database/migrations/seed/2026_03_21_100151_add_bills_permission.php @@ -0,0 +1,46 @@ + 'Facturas / Gastos' + ]); + + $billIndex = Permission::firstOrCreate(['name' => 'bills.index'], ['guard_name' => 'api', 'description' => 'Mostrar facturas', 'permission_type_id' => $type->id]); + $billCreate = Permission::firstOrCreate(['name' => 'bills.create'], ['guard_name' => 'api', 'description' => 'Subir facturas', 'permission_type_id' => $type->id]); + $billEdit = Permission::firstOrCreate(['name' => 'bills.edit'], ['guard_name' => 'api', 'description' => 'Editar facturas', 'permission_type_id' => $type->id]); + $billDestroy = Permission::firstOrCreate(['name' => 'bills.destroy'], ['guard_name' => 'api', 'description' => 'Eliminar facturas', 'permission_type_id' => $type->id]); + + // Asignar permisos + $developer = Role::findByName('developer', 'api'); + $developer->givePermissionTo($billIndex, $billCreate, $billEdit, $billDestroy); + $admin = Role::findByName('admin', 'api'); + $admin->givePermissionTo($billIndex, $billCreate, $billEdit, $billDestroy); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $admin = Role::findByName('admin', 'api'); + $admin->revokePermissionTo(['bills.index', 'bills.create', 'bills.edit', 'bills.destroy']); + + $developer = Role::findByName('developer', 'api'); + $developer->revokePermissionTo(['bills.index', 'bills.create', 'bills.edit', 'bills.destroy']); + + Permission::whereIn('name', ['bills.index', 'bills.create', 'bills.edit', 'bills.destroy'])->delete(); + + PermissionType::where('name', 'Facturas / Gastos')->delete(); + } +}; diff --git a/routes/api.php b/routes/api.php index c2cfe41..cb4925b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('admin.bills.')->group(function () { + Route::get('/', [BillController::class, 'index'] )->name('index'); + Route::post('/', [BillController::class, 'store'] )->name('store'); + Route::get('/pending/excel', [BillController::class, 'export'] )->name('export'); + Route::get('/{bill}', [BillController::class, 'show'] )->name('show'); + Route::post('/{bill}', [BillController::class, 'update'] )->name('update'); + Route::delete('/{bill}', [BillController::class, 'destroy'] )->name('destroy'); + Route::patch('/{bill}/toggle-paid', [BillController::class, 'togglePaid'])->name('toggle-paid'); + }); + // WHATSAPP Route::prefix('whatsapp')->group(function () { Route::post('/send-document', [WhatsappController::class, 'sendDocument']);