Initial commit: Sistema de comisiones y gastos personales

This commit is contained in:
2026-04-19 09:59:57 -06:00
commit dc964d6bce
103 changed files with 15859 additions and 0 deletions

18
.editorconfig Executable file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

44
.env.example Executable file
View File

@@ -0,0 +1,44 @@
# Nomina Pegaso - Configuración
APP_NAME="Nomina Pegaso"
APP_ENV=local
APP_KEY=base64:OKIa4LjLBL6WSZ7HZXKDVpFwJ/KMI6bljtmxWQEcHKY=
APP_DEBUG=true
APP_URL=http://nomina-pegaso.casa
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=es_MX
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=10.10.4.17
DB_PORT=3391
DB_DATABASE=nomina_pegaso
DB_USERNAME=nickpons666
DB_PASSWORD=MiPo6425@@
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
VITE_APP_NAME="${APP_NAME}"
# Telegram Bot
TELEGRAM_BOT_TOKEN=8324407449:AAF2awMeZ9pgSIp0MvV1r5owu8lO7SEK70E
TELEGRAM_WEBHOOK_URL=

11
.gitattributes vendored Executable file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

25
.gitignore vendored Executable file
View File

@@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

1
.npmrc Executable file
View File

@@ -0,0 +1 @@
ignore-scripts=true

42
README.md Executable file
View File

@@ -0,0 +1,42 @@
# Sistema de Comisiones y Gastos
Aplicación Laravel para control de ingresos por comisiones y gastos personales con soporte multiusuario y bot de Telegram integrado.
## Características
- **Gestión de Comisiones**: Registro y cálculo automático de comisiones por ventas
- **Control de Gastos**: Seguimiento de gastos personales con categorización (quincena 1, quincena 2, mensual)
- **Calendario Visual**: Visualización de ventas y gastos en un calendario interactivo
- **Configuración de Usuario**: Porcentaje de comisión, salario base, datos laborales
- **Datos Laborales**: Fecha de ingreso, razón social, sueldo íntegro diario (para cálculo de vacaciones)
- **Interfaz Responsive**: Diseño adaptado para dispositivos móviles
## Instalación
```bash
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan serve
```
## Estructura de la Base de Datos
- **users**: Usuarios del sistema con configuración de comisiones
- **months**: Meses del año fiscal
- **daily_sales**: Ventas diarias registradas
- **expenses**: Gastos personales categorizados
- **telegram_accounts**: Cuentas de Telegram vinculadas
## Rutas Principales
- `/dashboard` - Panel principal
- `/calendar` - Calendario de ventas y gastos
- `/sales` - Gestión de ventas
- `/expenses` - Gestión de gastos
- `/settings` - Configuración del usuario
## License
MIT License

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Mostrar formulario de login
*/
public function showLoginForm()
{
return view('auth.login');
}
/**
* Procesar login
*/
public function login(Request $request)
{
$credentials = $request->validate([
'username' => ['required', 'string'],
'password' => ['required'],
]);
$user = User::where('username', $credentials['username'])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'username' => ['Las credenciales no coinciden con nuestros registros.'],
]);
}
if (!$user->is_active) {
throw ValidationException::withMessages([
'username' => ['Tu cuenta está inactiva. Contacta al administrador.'],
]);
}
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
return redirect()->intended('/');
}
/**
* Mostrar formulario de registro
*/
public function showRegisterForm()
{
return view('auth.register');
}
/**
* Procesar registro
*/
public function register(Request $request)
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:50', 'unique:users,username'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'confirmed', 'min:8'],
'commission_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
'monthly_salary' => ['required', 'numeric', 'min:0'],
]);
$user = User::create([
'username' => $validated['username'],
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
'commission_percentage' => $validated['commission_percentage'],
'monthly_salary' => $validated['monthly_salary'],
'is_active' => true,
]);
Auth::login($user);
return redirect('/');
}
/**
* Cerrar sesión
*/
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/login');
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Models\DailySale;
use App\Models\Month;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class CalendarController extends Controller
{
/**
* Mostrar calendario interactivo
*/
public function index(Request $request)
{
$user = Auth::user();
$year = $request->get('year', now()->year);
$monthName = $request->get('month');
// Obtener meses del usuario
$months = $user->months()
->where('year', $year)
->get()
->keyBy('name');
// Si se especifica mes, mostrar ese mes
if ($monthName) {
$currentMonth = $months->get($monthName);
} else {
// Si no, buscar mes abierto o el último del año
$currentMonth = $user->months()
->where('year', $year)
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre')")
->first();
}
// Obtener ventas diarias del mes actual
$dailySales = [];
if ($currentMonth) {
$sales = $currentMonth->dailySales()->get()->keyBy('date');
foreach ($sales as $sale) {
$dailySales[$sale->date->format('Y-m-d')] = $sale;
}
}
// Obtener gastos del mes
$expenses = [];
if ($currentMonth) {
$expenseList = $currentMonth->expenses()->get();
foreach ($expenseList as $expense) {
$expenses[$expense->date->format('Y-m-d')] = $expense;
}
}
return view('calendar.index', compact('year', 'months', 'currentMonth', 'dailySales', 'expenses'));
}
/**
* Obtener datos de un día específico (para AJAX)
*/
public function day(Request $request)
{
$user = Auth::user();
$date = $request->get('date');
if (!$date) {
return response()->json(['error' => 'Fecha requerida'], 400);
}
// Buscar venta del día
$sale = DailySale::whereHas('month', function ($query) use ($user) {
$query->where('user_id', $user->id);
})
->where('date', $date)
->first();
// Buscar gastos del día
$expenses = $user->expenses()
->where('date', $date)
->get();
return response()->json([
'date' => $date,
'sale' => $sale,
'expenses' => $expenses,
]);
}
/**
* Guardar venta y gasto del día (para AJAX)
*/
public function storeDay(Request $request)
{
$user = Auth::user();
$data = $request->all();
$monthId = $request->input('month_id');
$date = $request->input('date');
$userSales = floatval($request->input('user_sales', 0));
$systemSales = floatval($request->input('system_sales', 0));
$expenseAmount = floatval($request->input('expense_amount', 0));
if (!$monthId || !$date) {
return response()->json(['success' => false, 'message' => 'Faltan datos requeridos']);
}
$month = Month::where('id', $monthId)
->where('user_id', $user->id)
->first();
if (!$month) {
return response()->json(['success' => false, 'message' => 'Mes no encontrado']);
}
// Guardar o actualizar venta solo si hay datos
if ($userSales > 0) {
DailySale::updateOrCreate(
[
'month_id' => $month->id,
'date' => $date,
],
[
'user_id' => $user->id,
'user_sales' => $userSales,
'system_sales' => $systemSales,
]
);
}
// Guardar o actualizar gasto si hay monto
$expenseType = $request->input('expense_type', 'q1');
if ($expenseAmount > 0) {
\App\Models\Expense::updateOrCreate(
[
'month_id' => $month->id,
'date' => $date,
],
[
'user_id' => $user->id,
'description' => $request->input('expense_description', 'Gasto del día'),
'amount' => $expenseAmount,
'expense_type' => $expenseType,
]
);
}
return response()->json(['success' => true, 'message' => 'Datos guardados correctamente']);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\DailySale;
use App\Models\Month;
use App\Services\CommissionCalculator;
use Illuminate\Support\Facades\Auth;
class DashboardController extends Controller
{
/**
* Mostrar dashboard del usuario
*/
public function index()
{
$user = Auth::user();
// Obtener mes actual o último mes
$currentMonth = $user->getCurrentMonth();
// Si no hay mes abierto, buscar el último mes
if (!$currentMonth) {
$currentMonth = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->first();
}
$data = null;
if ($currentMonth) {
$data = CommissionCalculator::calculateForMonth($user, $currentMonth);
}
// Últimos meses del usuario
$recentMonths = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->limit(6)
->get();
return view('dashboard.index', compact('currentMonth', 'data', 'recentMonths'));
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers;
use App\Models\Expense;
use App\Models\Month;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ExpenseController extends Controller
{
/**
* Listar todos los gastos
*/
public function index(Request $request)
{
$user = Auth::user();
$monthId = $request->get('month_id');
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
$expenses = $month->expenses()
->orderBy('date', 'desc')
->paginate(30);
} else {
$month = null;
$expenses = $user->expenses()
->orderBy('date', 'desc')
->paginate(30);
}
$months = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->get();
return view('expenses.index', compact('expenses', 'month', 'months'));
}
/**
* Mostrar formulario para crear gasto
*/
public function create(Request $request)
{
$user = Auth::user();
$monthId = $request->get('month_id');
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
} else {
$month = $user->getCurrentMonth();
if (!$month) {
$month = $user->months()->latest()->first();
}
}
if (!$month) {
return redirect()->route('months.index')->with('info', 'Necesitas un mes de trabajo primero.');
}
return view('expenses.create', compact('month'));
}
/**
* Guardar nuevo gasto
*/
public function store(Request $request)
{
$user = Auth::user();
$validated = $request->validate([
'month_id' => ['required', 'exists:months,id'],
'description' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0.01'],
'date' => ['required', 'date'],
'expense_type' => ['required', 'in:q1,q2,mensual'],
]);
// Verificar que el mes pertenece al usuario
$month = $user->months()->findOrFail($validated['month_id']);
$month->expenses()->create([
'user_id' => $user->id,
'description' => $validated['description'],
'amount' => $validated['amount'],
'date' => $validated['date'],
'expense_type' => $validated['expense_type'],
]);
return redirect()->route('expenses.index', ['month_id' => $month->id])
->with('success', 'Gasto registrado correctamente.');
}
/**
* Mostrar formulario de edición
*/
public function edit(Expense $expense)
{
$user = Auth::user();
if ($expense->user_id !== $user->id) {
abort(403);
}
return view('expenses.edit', compact('expense'));
}
/**
* Actualizar gasto
*/
public function update(Request $request, Expense $expense)
{
$user = Auth::user();
if ($expense->user_id !== $user->id) {
abort(403);
}
$validated = $request->validate([
'description' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0.01'],
'date' => ['required', 'date'],
'expense_type' => ['required', 'in:q1,q2,mensual'],
]);
$expense->update($validated);
return redirect()->route('expenses.index', ['month_id' => $expense->month_id])
->with('success', 'Gasto actualizado correctamente.');
}
/**
* Eliminar gasto
*/
public function destroy(Expense $expense)
{
$user = Auth::user();
if ($expense->user_id !== $user->id) {
abort(403);
}
$monthId = $expense->month_id;
$expense->delete();
return redirect()->route('expenses.index', ['month_id' => $monthId])
->with('success', 'Gasto eliminado correctamente.');
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers;
use App\Models\Month;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MonthController extends Controller
{
/**
* Listar todos los meses
*/
public function index()
{
$user = Auth::user();
$months = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Diciembre', 'Noviembre', 'Octubre', 'Septiembre', 'Agosto', 'Julio', 'Junio', 'Mayo', 'Abril', 'Marzo', 'Febrero', 'Enero')")
->paginate(12);
return view('months.index', compact('months'));
}
/**
* Mostrar formulario para crear mes
*/
public function create()
{
return view('months.create');
}
/**
* Guardar nuevo mes
*/
public function store(Request $request)
{
$user = Auth::user();
$validated = $request->validate([
'name' => ['required', 'string', 'max:50'],
'year' => ['required', 'integer', 'min:2020', 'max:2100'],
]);
// Verificar que no exista ya el mes para el usuario
$exists = $user->months()
->where('name', $validated['name'])
->where('year', $validated['year'])
->exists();
if ($exists) {
return back()->withErrors([
'name' => 'Ya existe un mes con ese nombre y año.',
])->withInput();
}
$user->months()->create([
'name' => $validated['name'],
'year' => $validated['year'],
'status' => 'open',
]);
return redirect()->route('months.index')
->with('success', 'Mes creado correctamente.');
}
/**
* Mostrar detalles del mes
*/
public function show(Month $month)
{
$user = Auth::user();
if ($month->user_id !== $user->id) {
abort(403);
}
$month->load(['dailySales', 'expenses']);
return view('months.show', compact('month'));
}
/**
* Mostrar formulario de edición
*/
public function edit(Month $month)
{
$user = Auth::user();
if ($month->user_id !== $user->id) {
abort(403);
}
return view('months.edit', compact('month'));
}
/**
* Actualizar mes
*/
public function update(Request $request, Month $month)
{
$user = Auth::user();
if ($month->user_id !== $user->id) {
abort(403);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:50'],
'year' => ['required', 'integer', 'min:2020', 'max:2100'],
'status' => ['required', 'in:open,closed,paid'],
]);
$month->update($validated);
return redirect()->route('months.show', $month->id)
->with('success', 'Mes actualizado correctamente.');
}
/**
* Cerrar mes
*/
public function close(Month $month)
{
$user = Auth::user();
if ($month->user_id !== $user->id) {
abort(403);
}
if ($month->status !== 'open') {
return back()->with('error', 'Solo se pueden cerrar meses abiertos.');
}
$month->update(['status' => 'closed']);
return redirect()->route('months.show', $month->id)
->with('success', 'Mes cerrado correctamente.');
}
/**
* Eliminar mes
*/
public function destroy(Month $month)
{
$user = Auth::user();
if ($month->user_id !== $user->id) {
abort(403);
}
// Verificar que no tenga ventas o gastos asociados
if ($month->dailySales()->count() > 0 || $month->expenses()->count() > 0) {
return back()->with('error', 'No puedes eliminar un mes que tiene ventas o gastos asociados.');
}
$month->delete();
return redirect()->route('months.index')
->with('success', 'Mes eliminado correctamente.');
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Models\Month;
use App\Services\CommissionCalculator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ReportController extends Controller
{
/**
* Mostrar reporte mensual
*/
public function monthly(Request $request)
{
$user = Auth::user();
$monthId = $request->get('month_id');
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
} else {
$month = $user->getCurrentMonth();
if (!$month) {
$month = $user->months()->latest()->first();
}
}
if (!$month) {
return redirect()->route('dashboard')->with('info', 'No hay meses disponibles.');
}
$report = CommissionCalculator::calculateForMonth($user, $month);
// Cargar ventas y gastos detalles
$dailySales = $month->dailySales()
->orderBy('date', 'asc')
->get();
$expenses = $month->expenses()
->orderBy('date', 'desc')
->get();
$months = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->get();
return view('reports.monthly', compact('report', 'month', 'dailySales', 'expenses', 'months'));
}
/**
* Mostrar reporte quincenal
*/
public function biweekly(Request $request)
{
$user = Auth::user();
$monthId = $request->get('month_id');
$biweekly = $request->get('biweekly', 1);
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
} else {
$month = $user->getCurrentMonth();
if (!$month) {
$month = $user->months()->latest()->first();
}
}
if (!$month) {
return redirect()->route('dashboard')->with('info', 'No hay meses disponibles.');
}
$report = CommissionCalculator::calculateBiweekly($user, $month, $biweekly);
// Obtener ventas del mes
$dailySales = $month->dailySales()
->orderBy('date', 'asc')
->get();
// Obtener gastos según la quincena seleccionada
$expenses = $month->expenses()
->where(function($query) use ($biweekly) {
if ($biweekly === 1) {
$query->where('expense_type', 'q1')
->orWhere('expense_type', 'mensual');
} else {
$query->where('expense_type', 'q2')
->orWhere('expense_type', 'mensual');
}
})
->orderBy('date', 'desc')
->get();
$months = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->get();
return view('reports.biweekly', compact('report', 'month', 'dailySales', 'expenses', 'months', 'biweekly'));
}
/**
* Resumen anual
*/
public function yearly(Request $request)
{
$user = Auth::user();
$year = $request->get('year', now()->year);
$report = CommissionCalculator::calculateYearly($user, $year);
// Obtener meses del año
$months = $user->months()
->where('year', $year)
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre')")
->get();
// Años disponibles
$years = $user->months()
->select('year')
->distinct()
->orderBy('year', 'desc')
->pluck('year');
return view('reports.yearly', compact('report', 'year', 'months', 'years'));
}
/**
* Obtener número del mes por nombre
*/
private static function getMonthNumber(string $monthName): int
{
$months = [
'Enero' => 1, 'Febrero' => 2, 'Marzo' => 3, 'Abril' => 4,
'Mayo' => 5, 'Junio' => 6, 'Julio' => 7, 'Agosto' => 8,
'Septiembre' => 9, 'Octubre' => 10, 'Noviembre' => 11, 'Diciembre' => 12
];
return $months[$monthName] ?? 1;
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers;
use App\Models\DailySale;
use App\Models\Month;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SaleController extends Controller
{
/**
* Listar todas las ventas del mes actual
*/
public function index(Request $request)
{
$user = Auth::user();
// Obtener mes seleccionado
$monthId = $request->get('month_id');
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
} else {
$month = $user->getCurrentMonth();
if (!$month) {
$month = $user->months()->latest()->first();
}
}
if (!$month) {
return redirect()->route('months.create')->with('info', 'Primero debes crear un mes de trabajo.');
}
$sales = $month->dailySales()
->orderBy('date', 'desc')
->paginate(31);
$months = $user->months()
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC")
->get();
return view('sales.index', compact('sales', 'month', 'months'));
}
/**
* Mostrar formulario para crear venta
*/
public function create(Request $request)
{
$user = Auth::user();
$monthId = $request->get('month_id');
if ($monthId) {
$month = $user->months()->findOrFail($monthId);
} else {
$month = $user->getCurrentMonth();
if (!$month) {
$month = $user->months()->latest()->first();
}
}
if (!$month) {
return redirect()->route('months.index')->with('info', 'Necesitas un mes de trabajo primero.');
}
return view('sales.create', compact('month'));
}
/**
* Guardar nueva venta
*/
public function store(Request $request)
{
$user = Auth::user();
$validated = $request->validate([
'month_id' => ['required', 'exists:months,id'],
'date' => ['required', 'date'],
'user_sales' => ['required', 'numeric', 'min:0'],
'system_sales' => ['nullable', 'numeric', 'min:0'],
]);
// Verificar que el mes pertenece al usuario
$month = $user->months()->findOrFail($validated['month_id']);
// Verificar si ya existe una venta para esa fecha
$existingSale = $month->dailySales()->where('date', $validated['date'])->first();
if ($existingSale) {
// Actualizar venta existente
$existingSale->update([
'user_sales' => $validated['user_sales'],
'system_sales' => $validated['system_sales'] ?? $existingSale->system_sales,
]);
return redirect()->route('sales.index', ['month_id' => $month->id])
->with('success', 'Venta actualizada correctamente.');
}
// Crear nueva venta
$month->dailySales()->create([
'user_id' => $user->id,
'date' => $validated['date'],
'user_sales' => $validated['user_sales'],
'system_sales' => $validated['system_sales'] ?? 0,
]);
return redirect()->route('sales.index', ['month_id' => $month->id])
->with('success', 'Venta registrada correctamente.');
}
/**
* Mostrar formulario de edición
*/
public function edit(DailySale $sale)
{
$user = Auth::user();
// Verificar que la venta pertenece al usuario
if ($sale->user_id !== $user->id) {
abort(403);
}
return view('sales.edit', compact('sale'));
}
/**
* Actualizar venta
*/
public function update(Request $request, DailySale $sale)
{
$user = Auth::user();
if ($sale->user_id !== $user->id) {
abort(403);
}
$validated = $request->validate([
'date' => ['required', 'date'],
'user_sales' => ['required', 'numeric', 'min:0'],
'system_sales' => ['nullable', 'numeric', 'min:0'],
]);
$sale->update($validated);
return redirect()->route('sales.index', ['month_id' => $sale->month_id])
->with('success', 'Venta actualizada correctamente.');
}
/**
* Eliminar venta
*/
public function destroy(DailySale $sale)
{
$user = Auth::user();
if ($sale->user_id !== $user->id) {
abort(403);
}
$monthId = $sale->month_id;
$sale->delete();
return redirect()->route('sales.index', ['month_id' => $monthId])
->with('success', 'Venta eliminada correctamente.');
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class SettingsController extends Controller
{
/**
* Mostrar configuración del usuario
*/
public function index()
{
$user = Auth::user();
return view('settings.index', compact('user'));
}
/**
* Actualizar configuración del usuario
*/
public function update(Request $request)
{
$user = Auth::user();
foreach (['fecha_ingreso', 'razon_social', 'sueldo_integro_diario'] as $field) {
if ($request->has($field) && $request->input($field) === '') {
$request->merge([$field => null]);
}
}
if ($request->has('commission_percentage')) {
$request->validate([
'commission_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
]);
$user->commission_percentage = floatval($request->input('commission_percentage', 0));
}
if ($request->has('monthly_salary')) {
$request->validate([
'monthly_salary' => ['required', 'numeric', 'min:0'],
]);
$user->monthly_salary = floatval($request->input('monthly_salary', 0));
}
if ($request->has('fecha_ingreso')) {
if ($request->filled('fecha_ingreso')) {
$user->fecha_ingreso = $request->input('fecha_ingreso');
} else {
$user->fecha_ingreso = null;
}
}
if ($request->has('razon_social')) {
if ($request->filled('razon_social')) {
$user->razon_social = $request->input('razon_social');
} else {
$user->razon_social = null;
}
}
if ($request->has('sueldo_integro_diario')) {
if ($request->filled('sueldo_integro_diario')) {
$user->sueldo_integro_diario = floatval($request->input('sueldo_integro_diario'));
} else {
$user->sueldo_integro_diario = null;
}
}
$user->save();
return redirect()->route('settings.index')->with('success', 'Configuración actualizada correctamente.');
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Models\TelegramAccount;
use App\Models\User;
use App\Services\TelegramBotService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class TelegramController extends Controller
{
/**
* Mostrar página de vinculación de Telegram
*/
public function showVerifyPage()
{
$user = Auth::user();
// Obtener o crear cuenta de Telegram
$telegramAccount = $user->telegramAccount()->first();
if (!$telegramAccount) {
// Generar nuevo código de verificación
$verificationCode = TelegramBotService::generateVerificationCode();
$telegramAccount = TelegramAccount::create([
'user_id' => $user->id,
'chat_id' => null,
'verification_code' => $verificationCode,
'is_verified' => false,
]);
} elseif (!$telegramAccount->is_verified && !$telegramAccount->verification_code) {
// Regenerar código si no existe
$verificationCode = TelegramBotService::generateVerificationCode();
$telegramAccount->update(['verification_code' => $verificationCode]);
}
return view('telegram.verify', compact('telegramAccount'));
}
/**
* Regenerar código de verificación
*/
public function regenerateCode(Request $request)
{
$user = Auth::user();
$telegramAccount = $user->telegramAccount;
if (!$telegramAccount) {
return back()->with('error', 'No tienes una cuenta de Telegram vinculada.');
}
if ($telegramAccount->is_verified) {
return back()->with('error', 'Tu cuenta ya está verificada.');
}
$verificationCode = TelegramBotService::generateVerificationCode();
$telegramAccount->update(['verification_code' => $verificationCode]);
return back()->with('success', 'Nuevo código de verificación generado.');
}
/**
* desvincular cuenta de Telegram
*/
public function unlink(Request $request)
{
$user = Auth::user();
$telegramAccount = $user->telegramAccount;
if ($telegramAccount) {
$telegramAccount->delete();
// Crear nueva cuenta sin verificar
TelegramAccount::create([
'user_id' => $user->id,
'chat_id' => null,
'verification_code' => TelegramBotService::generateVerificationCode(),
'is_verified' => false,
]);
}
return back()->with('success', 'Cuenta de Telegram desvinculada.');
}
/**
* Webhook para recibir mensajes de Telegram
*/
public function webhook(Request $request)
{
try {
$update = $request->all();
Log::info('Telegram webhook received', $update);
if (empty($update)) {
return response()->json(['ok' => true, 'message' => 'No update
received']);
}
$botService = new TelegramBotService();
$result = $botService->handleUpdate($update);
return response()->json($result);
} catch (\Exception $e) {
Log::error('Telegram webhook error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json(['ok' => false, 'error' => $e->getMessage()], 500);
}
}
/**
* Configurar webhook (ruta temporal para configurar desde el navegador)
*/
public function setupWebhook(Request $request)
{
$token = $request->get('token');
// Verificar token de seguridad simple
if ($token !== config('app.key')) {
abort(403, 'Token inválido');
}
$botService = new TelegramBotService();
$result = $botService->setWebhook();
if ($result) {
return response()->json(['success' => true, 'message' => 'Webhook configurado correctamente']);
}
return response()->json(['success' => false, 'error' => 'Error al configurar webhook'], 500);
}
}

67
app/Models/DailySale.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'month_id', 'date', 'user_sales', 'system_sales'])]
class DailySale extends Model
{
use HasFactory;
protected $table = 'daily_sales';
/**
* Los atributos que son asignables en masa.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'month_id',
'date',
'user_sales',
'system_sales',
];
/**
* Los atributos que deben ser convertidos.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
'user_sales' => 'decimal:2',
'system_sales' => 'decimal:2',
];
}
/**
* Relación con usuario
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Relación con mes
*/
public function month(): BelongsTo
{
return $this->belongsTo(Month::class);
}
/**
* Obtener la diferencia entre ventas usuario y sistema
*/
public function getDifferenceAttribute(): float
{
return $this->user_sales - $this->system_sales;
}
}

59
app/Models/Expense.php Executable file
View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'month_id', 'description', 'amount', 'date', 'expense_type'])]
class Expense extends Model
{
use HasFactory;
protected $table = 'expenses';
/**
* Los atributos que son asignables en masa.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'month_id',
'description',
'amount',
'date',
'expense_type',
];
/**
* Los atributos que deben ser convertidos.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
'amount' => 'decimal:2',
];
}
/**
* Relación con usuario
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Relación con mes
*/
public function month(): BelongsTo
{
return $this->belongsTo(Month::class);
}
}

97
app/Models/Month.php Executable file
View File

@@ -0,0 +1,97 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['user_id', 'name', 'year', 'status'])]
class Month extends Model
{
use HasFactory;
protected $table = 'months';
/**
* Los atributos que son asignables en masa.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'name',
'year',
'status',
];
/**
* Los atributos que deben ser convertidos.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'year' => 'integer',
];
}
/**
* Relación con usuario
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Relación con ventas diarias
*/
public function dailySales(): HasMany
{
return $this->hasMany(DailySale::class);
}
/**
* Relación con gastos
*/
public function expenses(): HasMany
{
return $this->hasMany(Expense::class);
}
/**
* Obtener el nombre del mes con formato
*/
public function getDisplayNameAttribute(): string
{
return $this->name . ' ' . $this->year;
}
/**
* Calcular ventas totales del mes
*/
public function getTotalUserSalesAttribute(): float
{
return $this->dailySales()->sum('user_sales');
}
/**
* Calcular ventas del sistema
*/
public function getTotalSystemSalesAttribute(): float
{
return $this->dailySales()->sum('system_sales');
}
/**
* Calcular gastos totales del mes
*/
public function getTotalExpensesAttribute(): float
{
return $this->expenses()->sum('amount');
}
}

48
app/Models/TelegramAccount.php Executable file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'chat_id', 'verification_code', 'is_verified'])]
class TelegramAccount extends Model
{
use HasFactory;
protected $table = 'telegram_accounts';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'chat_id',
'verification_code',
'is_verified',
];
/**
* The attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_verified' => 'boolean',
];
}
/**
* Relación con usuario
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

84
app/Models/User.php Executable file
View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['username', 'name', 'email', 'password', 'commission_percentage', 'monthly_salary', 'is_active', 'fecha_ingreso', 'razon_social', 'sueldo_integro_diario'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
protected $guarded = [];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'commission_percentage' => 'decimal:2',
'monthly_salary' => 'decimal:2',
'sueldo_integro_diario' => 'decimal:2',
'fecha_ingreso' => 'date',
'is_active' => 'boolean',
];
}
/**
* Relación con cuenta de Telegram
*/
public function telegramAccount(): HasOne
{
return $this->hasOne(TelegramAccount::class);
}
/**
* Relación con meses de trabajo
*/
public function months(): HasMany
{
return $this->hasMany(Month::class);
}
/**
* Relación con ventas diarias
*/
public function dailySales(): HasMany
{
return $this->hasMany(DailySale::class);
}
/**
* Relación con gastos
*/
public function expenses(): HasMany
{
return $this->hasMany(Expense::class);
}
/**
* Obtener el mes activo (si existe)
*/
public function getCurrentMonth(): ?Month
{
return $this->months()
->where('status', 'open')
->orderBy('year', 'desc')
->orderByRaw("FIELD(name, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre')")
->first();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Services;
use App\Models\DailySale;
use App\Models\Expense;
use App\Models\Month;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CommissionCalculator
{
/**
* Calcular comisión para un usuario en un mes específico
*/
public static function calculateForMonth(User $user, Month $month): array
{
// Verificar que el mes pertenece al usuario
if ($month->user_id !== $user->id) {
throw new \InvalidArgumentException('El mes no pertenece al usuario');
}
// Total de ventas del usuario
$totalUserSales = $month->dailySales()->sum('user_sales');
// Total de ventas del sistema
$totalSystemSales = $month->dailySales()->sum('system_sales');
// Total de gastos
$totalExpenses = $month->expenses()->sum('amount');
// Salario base
$monthlySalary = $user->monthly_salary;
// Porcentaje de comisión
$commissionPercentage = $user->commission_percentage;
// Calcular comisión basada en ventas del sistema (ventas consolidadas)
$commission = ($totalSystemSales * $commissionPercentage) / 100;
// Calcular percepción total (salario + comisión - gastos)
$totalEarning = $monthlySalary + $commission - $totalExpenses;
return [
'user_id' => $user->id,
'month_id' => $month->id,
'month_name' => $month->name . ' ' . $month->year,
'total_user_sales' => round($totalUserSales, 2),
'total_system_sales' => round($totalSystemSales, 2),
'total_expenses' => round($totalExpenses, 2),
'monthly_salary' => round($monthlySalary, 2),
'commission_percentage' => round($commissionPercentage, 2),
'commission_amount' => round($commission, 2),
'total_earning' => round($totalEarning, 2),
'has_difference' => ($totalUserSales !== $totalSystemSales),
'sales_difference' => round($totalUserSales - $totalSystemSales, 2),
];
}
/**
* Calcular quincena (primera o segunda)
*
* QUINCENA 1 (ANTICIPO): mitad salary + comisiones del MES completo
* QUINCENA 2 (LIQUIDACIÓN): mitad salary - gastos de la segunda quincena
*/
public static function calculateBiweekly(User $user, Month $month, int $biweekly): array
{
if ($month->user_id !== $user->id) {
throw new \InvalidArgumentException('El mes no pertenece al usuario');
}
$monthlySalary = $user->monthly_salary;
$biweeklySalary = $monthlySalary / 2; // Mitad del sueldo
$commissionPercentage = $user->commission_percentage;
// Get month number
$monthNumber = self::getMonthNumber($month->name);
$year = $month->year;
$lastDay = self::getLastDayOfMonth($month->name, $year);
if ($biweekly === 1) {
// =====================
// QUINCENA 1 - ANTICIPO
// =====================
// Anticipo = mitad del sueldo + comisiones del MES completo
$totalSystemSales = $month->dailySales()->sum('system_sales');
$commission = ($totalSystemSales * $commissionPercentage) / 100;
// Gastos: q1 completo + mensual/2
$expensesQ1Amount = $month->expenses()
->where(function($q) {
$q->where('expense_type', 'q1')
->orWhere('expense_type', 'mensual');
})
->get()
->sum(function($e) {
return $e->expense_type === 'mensual' ? $e->amount / 2 : $e->amount;
});
$totalEarning = $biweeklySalary + $commission - $expensesQ1Amount;
return [
'user_id' => $user->id,
'month_id' => $month->id,
'month_name' => $month->name . ' ' . $year,
'biweekly' => $biweekly,
'period' => '1ra Quincena (1-15) - ANTICIPO',
'description' => 'Mitad del sueldo + comisiones del mes completo',
'biweekly_salary' => round($biweeklySalary, 2),
'total_system_sales' => round($totalSystemSales, 2),
'commission_percentage' => round($commissionPercentage, 2),
'commission_amount' => round($commission, 2),
'total_expenses_month' => 0,
'expenses_q1' => round($expensesQ1Amount, 2),
'expenses_q2' => 0,
'total_earning' => round($totalEarning, 2),
'type' => 'anticipo',
];
} else {
// =====================
// QUINCENA 2 - LIQUIDACIÓN
// =====================
// Liquidación = mitad del sueldo - gastos de Q2
// Gastos: q2 completo + mensual/2
$expensesQ2Amount = $month->expenses()
->where(function($q) {
$q->where('expense_type', 'q2')
->orWhere('expense_type', 'mensual');
})
->get()
->sum(function($e) {
return $e->expense_type === 'mensual' ? $e->amount / 2 : $e->amount;
});
// Total a pagar en liquidacion
$totalEarning = $biweeklySalary - $expensesQ2Amount;
return [
'user_id' => $user->id,
'month_id' => $month->id,
'month_name' => $month->name . ' ' . $year,
'biweekly' => $biweekly,
'period' => "2da Quincena (16-$lastDay) - LIQUIDACIÓN",
'description' => 'Mitad del sueldo - mitad de gastos del mes',
'biweekly_salary' => round($biweeklySalary, 2),
'total_system_sales' => 0,
'expenses_q2' => round($expensesQ2Amount, 2),
'total_earning' => round($totalEarning, 2),
'type' => 'liquidacion',
];
}
}
/**
* Resumen anual del usuario
*/
public static function calculateYearly(User $user, int $year): array
{
$months = $user->months()->where('year', $year)->get();
$totalUserSales = 0;
$totalSystemSales = 0;
$totalExpenses = 0;
$totalSalary = 0;
$totalCommission = 0;
foreach ($months as $month) {
$totalUserSales += $month->dailySales()->sum('user_sales');
$totalSystemSales += $month->dailySales()->sum('system_sales');
$totalExpenses += $month->expenses()->sum('amount');
$totalSalary += $user->monthly_salary;
$totalCommission += ($month->dailySales()->sum('system_sales') * $user->commission_percentage) / 100;
}
return [
'user_id' => $user->id,
'year' => $year,
'months_count' => $months->count(),
'total_user_sales' => round($totalUserSales, 2),
'total_system_sales' => round($totalSystemSales, 2),
'total_expenses' => round($totalExpenses, 2),
'total_salary' => round($totalSalary, 2),
'total_commission' => round($totalCommission, 2),
'total_earning' => round($totalSalary + $totalCommission - $totalExpenses, 2),
];
}
/**
* Obtener número del mes por nombre
*/
private static function getMonthNumber(string $monthName): int
{
$months = [
'Enero' => 1, 'Febrero' => 2, 'Marzo' => 3, 'Abril' => 4,
'Mayo' => 5, 'Junio' => 6, 'Julio' => 7, 'Agosto' => 8,
'Septiembre' => 9, 'Octubre' => 10, 'Noviembre' => 11, 'Diciembre' => 12
];
return $months[$monthName] ?? 1;
}
/**
* Obtener último día del mes
*/
private static function getLastDayOfMonth(string $monthName, int $year): int
{
$monthNumber = self::getMonthNumber($monthName);
return (int) date('t', mktime(0, 0, 0, $monthNumber, 1, $year));
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Services;
use App\Models\TelegramAccount;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramBotService
{
private ?string $botToken;
private ?string $webhookUrl;
public function __construct()
{
$this->botToken = config('services.telegram.bot_token', env('TELEGRAM_BOT_TOKEN'));
$appUrl = config('app.url', env('APP_URL', 'http://nomina-pegaso.casa'));
$this->webhookUrl = rtrim($appUrl, '/') . '/telegram/webhook';
}
/**
* Procesar actualización recibida del webhook
*/
public function handleUpdate(array $update): array
{
if (!isset($update['message'])) {
return ['ok' => false, 'error' => 'No message found'];
}
$message = $update['message'];
$chatId = $message['chat']['id'];
$text = $message['text'] ?? '';
$from = $message['from'] ?? [];
Log::info('Telegram update received', [
'chat_id' => $chatId,
'text' => $text,
'from' => $from
]);
// Verificar si el usuario está verificado
$telegramAccount = TelegramAccount::where('chat_id', $chatId)->first();
if (!$telegramAccount || !$telegramAccount->is_verified) {
return $this->handleUnverifiedUser($chatId, $text);
}
// Procesar comandos del usuario verificado
return $this->handleCommand($telegramAccount->user, $text, $chatId);
}
/**
* Manejar usuario no verificado
*/
private function handleUnverifiedUser(string $chatId, string $text): array
{
// Si es un código de verificación
if (strlen($text) === 6 && is_numeric($text)) {
$telegramAccount = TelegramAccount::where('chat_id', $chatId)
->where('verification_code', $text)
->first();
if ($telegramAccount) {
$telegramAccount->update([
'is_verified' => true,
'verification_code' => null
]);
$user = $telegramAccount->user;
$this->sendMessage($chatId, "¡Verificación exitosa! Tu cuenta de Telegram está vinculada a {$user->name}. Ahora recibirás notificaciones de tus comisiones.");
return ['ok' => true, 'verified' => true];
} else {
$this->sendMessage($chatId, "Código de verificación inválido. Por favor intenta con el código correcto.");
return ['ok' => true, 'verified' => false];
}
}
// Mensaje de bienvenida para usuarios no verificados
$this->sendMessage($chatId, "¡Hola! Para usar este bot necesitas verificar tu cuenta.\n\nPor favor ingresa el código de verificación de 6 dígitos que encontrarás en la sección de Telegram de tu panel de usuario.");
return ['ok' => true, 'verified' => false];
}
/**
* Manejar comandos de usuario verificado
*/
private function handleCommand(User $user, string $text, string $chatId): array
{
$command = strtolower(trim($text));
$commandParts = explode(' ', $command);
$mainCommand = $commandParts[0] ?? '';
switch ($mainCommand) {
case '/start':
$this->sendMessage($chatId, "¡Hola {$user->name}! Usa /help para ver los comandos disponibles.");
break;
case '/help':
$this->sendHelp($chatId);
break;
case '/mes':
$this->showCurrentMonth($user, $chatId);
break;
case '/ventas':
$this->showSales($user, $chatId);
break;
case '/gastos':
$this->showExpenses($user, $chatId);
break;
case '/resumen':
$this->showSummary($user, $chatId);
break;
default:
$this->sendMessage($chatId, "Comando no reconocido. Usa /help para ver los comandos disponibles.");
}
return ['ok' => true];
}
/**
* Enviar mensaje
*/
public function sendMessage(string $chatId, string $text): array
{
if (!$this->botToken) {
Log::warning('Telegram bot token not configured');
return ['ok' => false, 'error' => 'Bot token not configured'];
}
try {
$response = Http::post("https://api.telegram.org/bot{$this->botToken}/sendMessage", [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown'
]);
return $response->json();
} catch (\Exception $e) {
Log::error('Telegram send message error', ['error' => $e->getMessage()]);
return ['ok' => false, 'error' => $e->getMessage()];
}
}
/**
* Enviar mensaje de ayuda
*/
private function sendHelp(string $chatId): void
{
$text = "📋 *Comandos disponibles:*\n\n" .
"• /start - Iniciar bot\n" .
"• /help - Mostrar ayuda\n" .
"• /mes - Ver mes actual\n" .
"• /ventas - Ver ventas del mes\n" .
"• /gastos - Ver gastos del mes\n" .
"• /resumen - Resumen de comisiones\n";
$this->sendMessage($chatId, $text);
}
/**
* Mostrar mes actual
*/
private function showCurrentMonth(User $user, string $chatId): void
{
$month = $user->getCurrentMonth();
if (!$month) {
$this->sendMessage($chatId, "No tienes ningún mes abierto actualmente.");
return;
}
$statusText = match($month->status) {
'open' => '🟢 Abierto',
'closed' => '🟡 Cerrado',
'paid' => '✅ Pagado',
default => 'Desconocido'
};
$text = "📅 *Mes Actual*\n\n" .
"• *Nombre:* {$month->name} {$month->year}\n" .
"• *Estado:* {$statusText}";
$this->sendMessage($chatId, $text);
}
/**
* Mostrar ventas del mes
*/
private function showSales(User $user, string $chatId): void
{
$month = $user->getCurrentMonth();
if (!$month) {
$this->sendMessage($chatId, "No tienes ningún mes abierto actualmente.");
return;
}
$totalUserSales = $month->dailySales()->sum('user_sales');
$totalSystemSales = $month->dailySales()->sum('system_sales');
$diff = $totalUserSales - $totalSystemSales;
$text = "💰 *Ventas del Mes*\n\n" .
"• *Usuario:* $" . number_format($totalUserSales, 2) . "\n" .
"• *Sistema:* $" . number_format($totalSystemSales, 2) . "\n" .
"• *Diferencia:* $" . number_format($diff, 2);
$this->sendMessage($chatId, $text);
}
/**
* Mostrar gastos del mes
*/
private function showExpenses(User $user, string $chatId): void
{
$month = $user->getCurrentMonth();
if (!$month) {
$this->sendMessage($chatId, "No tienes ningún mes abierto actualmente.");
return;
}
$totalExpenses = $month->expenses()->sum('amount');
$expensesList = $month->expenses()->latest()->limit(5)->get();
$text = "📝 *Gastos del Mes*\n\n" .
"• *Total:* $" . number_format($totalExpenses, 2) . "\n\n";
if ($expensesList->count() > 0) {
$text .= "Últimos gastos:\n";
foreach ($expensesList as $expense) {
$text .= "{$expense->description}: \$" . number_format($expense->amount, 2) . "\n";
}
}
$this->sendMessage($chatId, $text);
}
/**
* Mostrar resumen de comisiones
*/
private function showSummary(User $user, string $chatId): void
{
$month = $user->getCurrentMonth();
if (!$month) {
$this->sendMessage($chatId, "No tienes ningún mes abierto actualmente.");
return;
}
$data = CommissionCalculator::calculateForMonth($user, $month);
$text = "💵 *Resumen de Comisiones*\n\n" .
"• *Mes:* {$data['month_name']}\n" .
"• *Ventas Sistema:* \$" . number_format($data['total_system_sales'], 2) . "\n" .
"• *Comisión ({$data['commission_percentage']}%):* \$" . number_format($data['commission_amount'], 2) . "\n" .
"• *Salario:* \$" . number_format($data['monthly_salary'], 2) . "\n" .
"• *Gastos:* \$" . number_format($data['total_expenses'], 2) . "\n" .
"• *Total a Recibir:* \$" . number_format($data['total_earning'], 2);
$this->sendMessage($chatId, $text);
}
/**
* Generar código de verificación
*/
public static function generateVerificationCode(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
/**
* Configurar webhook
*/
public function setWebhook(): bool
{
if (!$this->botToken || !$this->webhookUrl) {
Log::warning('Cannot set webhook: missing configuration');
return false;
}
try {
$response = Http::post("https://api.telegram.org/bot{$this->botToken}/setWebhook", [
'url' => $this->webhookUrl
]);
return $response->json('ok', false);
} catch (\Exception $e) {
Log::error('Telegram set webhook error', ['error' => $e->getMessage()]);
return false;
}
}
}

18
artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

18
bootstrap/app.php Executable file
View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Executable file
View File

@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

85
composer.json Executable file
View File

@@ -0,0 +1,85 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8153
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

126
config/app.php Executable file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Executable file
View File

@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

130
config/cache.php Executable file
View File

@@ -0,0 +1,130 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Executable file
View File

@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Executable file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Executable file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Executable file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
config/queue.php Executable file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Executable file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
config/session.php Executable file
View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

1
database/.gitignore vendored Executable file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->decimal('commission_percentage', 5, 2)->default(10.00)->comment('Porcentaje de comisión');
$table->decimal('monthly_salary', 10, 2)->default(0)->comment('Salario mensual base');
$table->boolean('is_active')->default(true)->comment('Si el usuario está activo');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username')->unique()->after('id')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('telegram_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('chat_id')->unique()->nullable();
$table->string('verification_code', 6)->nullable();
$table->boolean('is_verified')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('telegram_accounts');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('months', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name'); // Nombre del mes (Enero, Febrero, etc.)
$table->integer('year'); // Año
$table->enum('status', ['open', 'closed', 'paid'])->default('open')->comment('Estado del mes');
$table->timestamps();
$table->unique(['user_id', 'name', 'year']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('months');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('daily_sales', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('month_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('user_sales', 12, 2)->default(0)->comment('Ventas reportadas por el usuario');
$table->decimal('system_sales', 12, 2)->default(0)->comment('Ventas del sistema (conciliadas)');
$table->timestamps();
$table->unique(['month_id', 'date']);
$table->index('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('daily_sales');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('expenses', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('month_id')->constrained()->onDelete('cascade');
$table->string('description');
$table->decimal('amount', 10, 2);
$table->date('date');
$table->timestamps();
$table->index('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('expenses');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
public function up(): void
{
Schema::table('telegram_accounts', function (Blueprint $table) {
$table->string('chat_id')->nullable()->change();
});
}
public function down(): void
{
Schema::table('telegram_accounts', function (Blueprint $table) {
$table->string('chat_id')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('expenses', function (Blueprint $table) {
$table->enum('expense_type', ['q1', 'q2', 'mensual'])->default('q1')->after('amount');
$table->index('expense_type');
});
}
public function down(): void
{
Schema::table('expenses', function (Blueprint $table) {
$table->dropColumn('expense_type');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('fecha_ingreso')->nullable()->after('monthly_salary');
$table->string('razon_social')->nullable()->after('fecha_ingreso');
$table->decimal('sueldo_integro_diario', 10, 2)->nullable()->after('razon_social');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['fecha_ingreso', 'razon_social', 'sueldo_integro_diario']);
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

16
package.json Executable file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.0.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
}
}

36
phpunit.xml Executable file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Executable file
View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

0
public/favicon.ico Executable file
View File

20
public/index.php Executable file
View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

242
public/js/app.js Executable file
View File

@@ -0,0 +1,242 @@
/**
* Nómina Pegaso - Global JavaScript Functions
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize all components
initTooltips();
initAnimations();
initFormValidation();
initDeleteConfirmations();
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(function(alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
});
/**
* Initialize Bootstrap tooltips
*/
function initTooltips() {
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
}
/**
* Add subtle animations on page load
*/
function initAnimations() {
// Fade in cards
const cards = document.querySelectorAll('.card');
cards.forEach(function(card, index) {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
setTimeout(function() {
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 100);
});
// Animate stat numbers
const statNumbers = document.querySelectorAll('.stat-number');
statNumbers.forEach(function(el) {
const target = parseFloat(el.getAttribute('data-target'));
if (target) {
animateValue(el, 0, target, 1000);
}
});
}
/**
* Animate number counting
*/
function animateValue(el, start, end, duration) {
let startTimestamp = null;
const step = function(timestamp) {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
const value = progress * (end - start) + start;
el.textContent = formatCurrency(value);
if (progress < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
}
/**
* Format currency
*/
function formatCurrency(amount) {
return '$' + parseFloat(amount).toLocaleString('es-ES', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
/**
* Initialize form validation feedback
*/
function initFormValidation() {
const forms = document.querySelectorAll('.needs-validation');
forms.forEach(function(form) {
form.addEventListener('submit', function(event) {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
}
/**
* Add confirmation to delete buttons
*/
function initDeleteConfirmations() {
const deleteButtons = document.querySelectorAll('.btn-delete');
deleteButtons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
if (!confirm('¿Estás seguro de que deseas eliminar este elemento?')) {
e.preventDefault();
return false;
}
});
});
}
/**
* Format date for display
*/
window.formatDate = function(dateStr, format = 'short') {
if (!dateStr) return '';
const date = new Date(dateStr);
if (format === 'short') {
return date.toLocaleDateString('es-ES');
}
return date.toLocaleDateString('es-ES', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
/**
* Show toast notification
*/
window.showToast = function(message, type = 'info') {
const toastContainer = document.querySelector('.toast-container');
if (!toastContainer) return;
const toast = document.createElement('div');
toast.className = `toast show align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
const icons = {
success: 'bi-check-circle',
error: 'bi-exclamation-circle',
warning: 'bi-exclamation-triangle',
info: 'bi-info-circle'
};
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="${icons[type] || 'bi-info-circle'} me-2"></i>
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
setTimeout(function() {
toast.remove();
}, 5000);
};
/**
* Mobile sidebar functionality
*/
document.addEventListener('DOMContentLoaded', function() {
const mobileNavBtn = document.getElementById('mobileNavBtn');
const mobileNavBtn2 = document.getElementById('mobileNavBtn2');
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebarOverlay');
function toggleSidebar() {
if (sidebar && sidebarOverlay) {
sidebar.classList.toggle('show');
sidebarOverlay.classList.toggle('show');
}
}
if (mobileNavBtn) {
mobileNavBtn.addEventListener('click', toggleSidebar);
}
if (mobileNavBtn2) {
mobileNavBtn2.addEventListener('click', toggleSidebar);
}
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', toggleSidebar);
}
// Close sidebar on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && sidebar && sidebar.classList.contains('show')) {
toggleSidebar();
}
});
});
/**
* Helper to parse numeric input
*/
window.parseNumber = function(value, decimals = 2) {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parseFloat(parsed.toFixed(decimals));
};
/**
* Calculate percentage
*/
window.calculatePercentage = function(value, total) {
if (total === 0) return 0;
return ((value / total) * 100).toFixed(2);
};
/**
* Debounce function for search inputs
*/
window.debounce = function(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};

138
public/js/calendar.js Executable file
View File

@@ -0,0 +1,138 @@
/**
* Calendar JavaScript - FullCalendar Configuration
* Nómina Pegaso
*/
document.addEventListener('DOMContentLoaded', function() {
// Calendar initialization is now handled in the blade template
// This file provides additional utility functions
/**
* Format currency values
*/
window.formatCurrency = function(amount) {
return '$' + parseFloat(amount).toLocaleString('es-ES', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
/**
* Format date for display
*/
window.formatDate = function(dateStr, format = 'long') {
const date = new Date(dateStr + 'T00:00:00');
if (format === 'short') {
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
return date.toLocaleDateString('es-ES', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
/**
* Show notification toast
*/
window.showToast = function(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast show align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
const container = document.querySelector('.toast-container') || createToastContainer();
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 5000);
};
function createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
/**
* Confirm action with modal
*/
window.confirmAction = function(message, onConfirm) {
if (confirm(message)) {
onConfirm();
}
};
/**
* Validate date is within current month
*/
window.validateDateInMonth = function(dateInput, monthId) {
// This can be extended to validate against specific month
const selectedDate = new Date(dateInput.value);
const now = new Date();
return selectedDate <= now;
};
/**
* Calculate difference between two values
*/
window.calculateDifference = function(value1, value2) {
const diff = parseFloat(value1) - parseFloat(value2);
const sign = diff >= 0 ? '+' : '';
return sign + formatCurrency(diff);
};
/**
* Update summary cards dynamically
*/
window.updateSummaryCard = function(cardId, value) {
const card = document.getElementById(cardId);
if (card) {
card.textContent = formatCurrency(value);
}
};
});
/**
* Mobile sidebar toggle
*/
document.addEventListener('DOMContentLoaded', function() {
const mobileNavBtn = document.getElementById('mobileNavBtn');
const mobileNavBtn2 = document.getElementById('mobileNavBtn2');
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebarOverlay');
function toggleSidebar() {
sidebar.classList.toggle('show');
sidebarOverlay.classList.toggle('show');
}
if (mobileNavBtn) mobileNavBtn.addEventListener('click', toggleSidebar);
if (mobileNavBtn2) mobileNavBtn2.addEventListener('click', toggleSidebar);
if (sidebarOverlay) sidebarOverlay.addEventListener('click', toggleSidebar);
// Close sidebar on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && sidebar.classList.contains('show')) {
toggleSidebar();
}
});
});

2
public/robots.txt Executable file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

11
resources/css/app.css Executable file
View File

@@ -0,0 +1,11 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

1
resources/js/app.js Executable file
View File

@@ -0,0 +1 @@
//

View File

@@ -0,0 +1,53 @@
@extends('layouts.guest')
@section('title', 'Iniciar Sesión')
@section('content')
<div class="auth-card p-4">
<h3 class="text-center mb-4">
<i class="bi bi-person-circle"></i> Nómina Pegaso
</h3>
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="mb-3">
<label for="username" class="form-label">Usuario</label>
<input type="text" class="form-control" id="username" name="username"
value="{{ old('username') }}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Recordarme</label>
</div>
<button type="submit" class="btn btn-primary w-100">Iniciar Sesión</button>
</form>
<div class="text-center mt-3">
<a href="{{ route('register') }}">¿No tienes cuenta? Regístrate</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,84 @@
@extends('layouts.guest')
@section('title', 'Registrarse')
@section('content')
<div class="auth-card p-4">
<h3 class="text-center mb-4">
<i class="bi bi-person-plus"></i> Registrarse
</h3>
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('register') }}">
@csrf
<div class="mb-3">
<label for="username" class="form-label">Nombre de Usuario</label>
<input type="text" class="form-control" id="username" name="username"
value="{{ old('username') }}" required>
<small class="text-muted">Único - sin espacios ni acentos</small>
</div>
<div class="mb-3">
<label for="name" class="form-label">Nombre Completo</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ old('name') }}" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="email" name="email"
value="{{ old('email') }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="password" name="password" required>
<small class="text-muted">Mínimo 8 caracteres</small>
</div>
<div class="mb-3">
<label for="password_confirmation" class="form-label">Confirmar Contraseña</label>
<input type="password" class="form-control" id="password_confirmation"
name="password_confirmation" required>
</div>
<hr>
<h5 class="mb-3">Configuración de Comisión</h5>
<div class="mb-3">
<label for="commission_percentage" class="form-label">Porcentaje de Comisión (%)</label>
<input type="number" class="form-control" id="commission_percentage"
name="commission_percentage" value="{{ old('commission_percentage', 10) }}"
min="0" max="100" step="0.01" required>
<small class="text-muted">Porcentaje que recibirás sobre tus ventas</small>
</div>
<div class="mb-3">
<label for="monthly_salary" class="form-label">Salario Mensual Base</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="monthly_salary"
name="monthly_salary" value="{{ old('monthly_salary', 0) }}"
min="0" step="0.01" required>
</div>
</div>
<button type="submit" class="btn btn-success w-100">Registrarse</button>
</form>
<div class="text-center mt-3">
<a href="{{ route('login') }}">¿Ya tienes cuenta? Inicia Sesión</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,584 @@
@extends('layouts.app')
@section('title', 'Calendario')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">
<i class="bi bi-calendar3 text-primary"></i> Calendario de Ventas y Gastos
</h2>
</div>
</div>
<!-- Selector de mes -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="GET" class="d-flex gap-2 flex-wrap">
<select name="year" class="form-select" style="width: auto;" onchange="this.form.submit()">
@php($years = range(date('Y'), date('Y') - 5))
@foreach($years as $y)
<option value="{{ $y }}" {{ $year == $y ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
<select name="month" class="form-select" style="width: auto;" onchange="this.form.submit()">
<option value="">Todos los meses</option>
@foreach(['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] as $m)
<option value="{{ $m }}" {{ request('month') == $m ? 'selected' : '' }}>{{ $m }}</option>
@endforeach
</select>
</form>
</div>
</div>
</div>
<div class="col-md-6 text-end">
@if($currentMonth)
<span class="badge bg-primary fs-6">
<i class="bi bi-calendar"></i> {{ $currentMonth->name }} {{ $currentMonth->year }}
</span>
<span class="badge bg-{{ $currentMonth->status === 'open' ? 'success' : ($currentMonth->status === 'closed' ? 'warning' : 'info') }}">
{{ ucfirst($currentMonth->status) }}
</span>
@endif
</div>
</div>
@if($currentMonth)
<!-- Resumen del mes -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card stat-card primary">
<div class="card-body">
<h6 class="text-muted">Ventas del Usuario</h6>
<h4 class="text-primary">${{ number_format($currentMonth->dailySales()->sum('user_sales'), 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card success">
<div class="card-body">
<h6 class="text-muted">Ventas del Sistema</h6>
<h4 class="text-success">${{ number_format($currentMonth->dailySales()->sum('system_sales'), 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card warning">
<div class="card-body">
<h6 class="text-muted">Total Gastos</h6>
<h4 class="text-warning">${{ number_format($currentMonth->expenses()->sum('amount'), 2) }}</h4>
</div>
</div>
</div>
</div>
<!-- Calendario FullCalendar -->
<div class="card mb-4">
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<!-- Leyenda -->
<div class="row mt-3">
<div class="col-12">
<div class="d-flex gap-3 flex-wrap">
<span class="badge bg-light text-dark border p-2">
<i class="bi bi-square text-secondary"></i> Sin datos
</span>
<span class="badge bg-success-subtle text-success border p-2">
<i class="bi bi-square text-success"></i> Con ventas
</span>
<span class="badge bg-danger-subtle text-danger border p-2">
<i class="bi bi-square text-danger"></i> Con gastos
</span>
<span class="badge bg-warning-subtle text-warning border p-2">
<i class="bi bi-square text-warning"></i> Con ambos
</span>
</div>
</div>
</div>
@else
<div class="alert alert-info">
<h5><i class="bi bi-info-circle"></i> No hay un mes seleccionado</h5>
<p>Selecciona un mes del dropdown o crea uno nuevo.</p>
<a href="{{ route('months.index') }}" class="btn btn-primary">Ver Meses</a>
</div>
@endif
<!-- Modal para capturar ventas -->
<div class="modal fade" id="dayModal" tabindex="-1" aria-labelledby="dayModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="dayModalLabel">
<i class="bi bi-calendar-plus"></i> Capturar Día
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" id="dayForm">
@csrf
<div class="modal-body">
<input type="hidden" name="month_id" id="formMonthId" value="{{ $currentMonth->id ?? '' }}">
<input type="hidden" name="date" id="modalDate">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Fecha: <strong id="modalDateDisplay"></strong>
</div>
<!-- Ventas -->
<h6 class="border-bottom pb-2 mb-3">
<i class="bi bi-currency-dollar text-success"></i> Ventas
</h6>
<div class="mb-3">
<label for="user_sales" class="form-label">Ventas del Usuario</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="user_sales" name="user_sales"
value="0" step="0.01" min="0">
</div>
</div>
<div class="mb-3">
<label for="system_sales" class="form-label">Ventas del Sistema</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="system_sales" name="system_sales"
value="0" step="0.01" min="0">
</div>
</div>
<!-- Gastos -->
<h6 class="border-bottom pb-2 mb-3 mt-4">
<i class="bi bi-receipt text-danger"></i> Gastos
</h6>
<div class="mb-3">
<label for="expense_description" class="form-label">Descripción del Gasto</label>
<input type="text" class="form-control" id="expense_description" name="expense_description"
placeholder="Opcional">
</div>
<div class="mb-3">
<label for="expense_amount" class="form-label">Monto del Gasto</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="expense_amount" name="expense_amount"
value="0" step="0.01" min="0">
</div>
</div>
<div class="mb-3">
<label for="expense_type" class="form-label">Quincena del Gasto</label>
<select class="form-select" id="expense_type" name="expense_type">
<option value="q1">1ra Quincena (1-15)</option>
<option value="q2">2da Quincena (16-31)</option>
<option value="mensual">Gasto Mensual (se divide en 2)</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Guardar
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal para ver detalles del día -->
<div class="modal fade" id="dayDetailModal" tabindex="-1" aria-labelledby="dayDetailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title" id="dayDetailModalLabel">
<i class="bi bi-eye"></i> Detalles del Día
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>Fecha:</strong> <span id="detailDate"></span></p>
<div class="row mb-3">
<div class="col-6">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="text-muted">Ventas Usuario</h6>
<h5 class="text-success" id="detailUserSales">$0.00</h5>
</div>
</div>
</div>
<div class="col-6">
<div class="card bg-light">
<div class="card-body text-center">
<h6 class="text-muted">Ventas Sistema</h6>
<h5 class="text-info" id="detailSystemSales">$0.00</h5>
</div>
</div>
</div>
</div>
<div class="card bg-light">
<div class="card-body">
<h6 class="text-muted">Gastos del Día</h6>
<div id="detailExpenses">
<p class="text-muted mb-0">No hay gastos registrados</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="button" class="btn btn-primary" id="editDayBtn">
<i class="bi bi-pencil"></i> Editar
</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Data for calendar events
const dailySales = @json($dailySales ?? []);
const expenses = @json($expenses ?? []);
const year = {{ $year }};
const currentMonthId = {{ $currentMonth->id ?? 0 }};
console.log('Month ID:', currentMonthId);
console.log('DailySales raw:', dailySales);
console.log('DailySales type:', typeof dailySales);
console.log('Expenses:', expenses);
// Map data to calendar events
const events = [];
// Add sales events - iterate over object values
Object.keys(dailySales).forEach(date => {
const saleData = dailySales[date];
if (saleData && saleData.user_sales > 0) {
events.push({
title: 'V: $' + parseFloat(saleData.user_sales).toLocaleString(),
start: date,
className: 'bg-success',
extendedProps: {
type: 'sale',
user_sales: saleData.user_sales,
system_sales: saleData.system_sales
}
});
}
});
// Add expense events
Object.keys(expenses).forEach(date => {
const expData = expenses[date];
if (expData && expData.amount > 0) {
events.push({
title: 'G: $' + parseFloat(expData.amount).toLocaleString(),
start: date,
className: 'bg-danger',
extendedProps: {
type: 'expense',
amount: expData.amount,
description: expData.description
}
});
}
});
console.log('Events:', events);
const calendarEl = document.getElementById('calendar');
// FullCalendar initialization
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
initialDate: year + '-04-01',
locale: 'es',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,listMonth'
},
buttonText: {
today: 'Hoy',
month: 'Mes',
list: 'Lista'
},
dayMaxEvents: true,
eventDisplay: 'block',
events: events,
dateClick: function(info) {
// Verify month exists
const monthId = {{ $currentMonth->id ?? 0 }};
if (!monthId || monthId === 0) {
console.log('No hay mes seleccionado');
return;
}
openDayModal(info.dateStr);
},
eventClick: function(info) {
showDayDetails(info.event.startStr);
},
eventDidMount: function(info) {
// Add custom styling based on data
const date = info.event.startStr;
if (dailySales[date] && expenses[date]) {
info.el.style.background = 'linear-gradient(135deg, #f39c12 0%, #e74c3c 100%)';
} else if (dailySales[date]) {
info.el.style.background = 'linear-gradient(135deg, #27ae60 0%, #2ecc71 100%)';
} else if (expenses[date]) {
info.el.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
}
}
});
calendar.render();
// Initialize selected date variable
window.selectedDate = null;
// Functions
window.openDayModal = function(dateStr) {
const modalEl = document.getElementById('dayModal');
if (!modalEl) return;
const modal = new bootstrap.Modal(modalEl);
const modalDateEl = document.getElementById('modalDate');
const modalDateDisplayEl = document.getElementById('modalDateDisplay');
if (modalDateEl && modalDateDisplayEl) {
modalDateEl.value = dateStr;
modalDateDisplayEl.textContent = formatDate(dateStr);
}
// Check if there's existing data
const userSalesEl = document.getElementById('user_sales');
const systemSalesEl = document.getElementById('system_sales');
const expenseDescEl = document.getElementById('expense_description');
const expenseAmountEl = document.getElementById('expense_amount');
if (userSalesEl && systemSalesEl) {
// Buscar coincidencia parcial
let saleData = dailySales[dateStr];
if (!saleData) {
// Buscar en claves
for (const key in dailySales) {
if (key.includes(dateStr) || dateStr.includes(key)) {
saleData = dailySales[key];
break;
}
}
}
if (saleData) {
userSalesEl.value = saleData.user_sales;
systemSalesEl.value = saleData.system_sales;
} else {
userSalesEl.value = 0;
systemSalesEl.value = 0;
}
}
if (expenseDescEl && expenseAmountEl) {
let expData = expenses[dateStr];
if (!expData) {
for (const key in expenses) {
if (key.includes(dateStr) || dateStr.includes(key)) {
expData = expenses[key];
break;
}
}
}
if (expData) {
expenseDescEl.value = expData.description || '';
expenseAmountEl.value = expData.amount;
} else {
expenseDescEl.value = '';
expenseAmountEl.value = 0;
}
}
modal.show();
};
window.showDayDetails = function(dateStr) {
const modal = new bootstrap.Modal(document.getElementById('dayDetailModal'));
document.getElementById('detailDate').textContent = formatDate(dateStr);
// Buscar en todas las claves
let foundKey = null;
for (const key in dailySales) {
if (key.includes(dateStr) || dateStr.includes(key)) {
foundKey = key;
break;
}
}
if (foundKey) {
// Found
document.getElementById('detailUserSales').textContent = '$' + dailySales[foundKey].user_sales.toLocaleString();
document.getElementById('detailSystemSales').textContent = '$' + dailySales[foundKey].system_sales.toLocaleString();
} else {
document.getElementById('detailUserSales').textContent = '$0.00';
document.getElementById('detailSystemSales').textContent = '$0.00';
}
if (expenses[dateStr]) {
document.getElementById('detailExpenses').innerHTML = `
<p class="mb-1"><strong>${expenses[dateStr].description || 'Gasto'}</strong></p>
<h5 class="text-danger mb-0">$${expenses[dateStr].amount.toLocaleString()}</h5>
`;
} else {
document.getElementById('detailExpenses').innerHTML = '<p class="text-muted mb-0">No hay gastos registrados</p>';
}
// Set up edit button
document.getElementById('editDayBtn').onclick = function() {
window.selectedDate = dateStr;
modal.hide();
setTimeout(() => openDayModal(dateStr), 300);
};
modal.show();
};
function formatDate(dateStr) {
if (!dateStr) return 'Sin fecha';
const parts = dateStr.split('-');
if (parts.length === 3) {
const year = parseInt(parts[0]);
const month = parseInt(parts[1]) - 1;
const day = parseInt(parts[2]);
const date = new Date(year, month, day);
return date.toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' });
}
return dateStr;
}
// Form submission handler
document.getElementById('dayForm').addEventListener('submit', function(e) {
e.preventDefault();
// Get values directly from DOM elements
const monthIdEl = document.getElementById('formMonthId');
const dateEl = document.getElementById('modalDate');
const userSalesEl = document.getElementById('user_sales');
const systemSalesEl = document.getElementById('system_sales');
const expenseAmountEl = document.getElementById('expense_amount');
const expenseDescEl = document.getElementById('expense_description');
const expenseTypeEl = document.getElementById('expense_type');
let monthId = monthIdEl ? monthIdEl.value : '';
let date = dateEl ? dateEl.value : '';
// Use window.selectedDate as fallback
if (!date && window.selectedDate) date = window.selectedDate;
// Try fallback
if (!monthId) monthId = '{{ $currentMonth->id ?? 0 }}';
const userSales = userSalesEl ? (parseFloat(userSalesEl.value) || 0) : 0;
const systemSales = systemSalesEl ? (parseFloat(systemSalesEl.value) || 0) : 0;
const expenseAmount = expenseAmountEl ? (parseFloat(expenseAmountEl.value) || 0) : 0;
const expenseDesc = expenseDescEl ? expenseDescEl.value : '';
const expenseType = expenseTypeEl ? expenseTypeEl.value : 'q1';
// Save data
if (!monthId || !date) {
return;
}
const data = {
month_id: monthId,
date: date,
user_sales: userSales,
system_sales: systemSales,
expense_description: expenseDesc,
expense_amount: expenseAmount,
expense_type: expenseType
};
// Send data via fetch
fetch('{{ route("calendar.day.store") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
console.error('Error saving:', data.message);
}
})
.catch(error => {
console.error('Error:', error);
});
});
});
</script>
@endpush
@push('styles')
<style>
.fc {
font-size: 0.9rem;
}
.fc .fc-toolbar-title {
font-size: 1.2rem;
}
.fc .fc-daygrid-day-number {
padding: 8px;
}
.fc-event {
padding: 2px 4px;
font-size: 0.75rem;
border-radius: 4px;
margin: 2px 0;
}
.fc .fc-col-header-cell-cushion {
padding: 8px;
font-weight: 600;
}
.fc-daygrid-day.has-data {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
}
.fc-daygrid-day.has-expense {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
}
.fc-daygrid-day.has-both {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
}
.day-cell-clickable {
cursor: pointer;
transition: background 0.2s;
}
.day-cell-clickable:hover {
background: #e3f2fd !important;
}
</style>
@endpush

View File

@@ -0,0 +1,170 @@
@extends('layouts.app')
@section('title', 'Dashboard')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Bienvenido, {{ auth()->user()->name }}</h2>
</div>
</div>
@if($currentMonth)
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="bi bi-calendar3"></i>
Resumen de {{ $currentMonth->name }} {{ $currentMonth->year }}
@if($currentMonth->status === 'open')
<span class="badge bg-success ms-2">Abierto</span>
@elseif($currentMonth->status === 'closed')
<span class="badge bg-warning ms-2">Cerrado</span>
@else
<span class="badge bg-info ms-2">Pagado</span>
@endif
</h5>
</div>
<div class="card-body">
@if($data)
<div class="row">
<div class="col-md-3">
<div class="card stat-card primary mb-3">
<div class="card-body">
<h6 class="text-muted">Ventas del Usuario</h6>
<h4>${{ number_format($data['total_user_sales'], 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card success mb-3">
<div class="card-body">
<h6 class="text-muted">Ventas del Sistema</h6>
<h4>${{ number_format($data['total_system_sales'], 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card warning mb-3">
<div class="card-body">
<h6 class="text-muted">Comisión ({{ $data['commission_percentage'] }}%)</h6>
<h4>${{ number_format($data['commission_amount'], 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card danger mb-3">
<div class="card-body">
<h6 class="text-muted">Total a Recibir</h6>
<h4>${{ number_format($data['total_earning'], 2) }}</h4>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center">
Salario Mensual
<span class="badge bg-primary rounded-pill">${{ number_format($data['monthly_salary'], 2) }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Gastos del Mes
<span class="badge bg-danger rounded-pill">${{ number_format($data['total_expenses'], 2) }}</span>
</li>
@if($data['has_difference'])
<li class="list-group-item d-flex justify-content-between align-items-center">
Diferencia Ventas
<span class="badge bg-warning rounded-pill">${{ number_format($data['sales_difference'], 2) }}</span>
</li>
@endif
</ul>
</div>
<div class="col-md-6 text-end">
<a href="{{ route('reports.monthly', ['month_id' => $currentMonth->id]) }}" class="btn btn-primary">
<i class="bi bi-file-earmark-text"></i> Ver Reporte Mensual
</a>
<a href="{{ route('reports.biweekly', ['month_id' => $currentMonth->id, 'biweekly' => 1]) }}" class="btn btn-outline-primary">
<i class="bi bi-file-earmark-text"></i> Ver Quincena
</a>
</div>
</div>
@else
<div class="alert alert-info">
No hay datos suficientes para calcular el resumen. Agrega ventas y gastos.
</div>
@endif
</div>
</div>
</div>
</div>
@else
<div class="alert alert-warning">
<h5>No hay un mes de trabajo activo</h5>
<p>Para comenzar, crea un nuevo mes de trabajo.</p>
<a href="{{ route('months.create') }}" class="btn btn-primary">Crear Mes</a>
</div>
@endif
<!-- Últimos meses -->
<div class="row mt-4">
<div class="col-12">
<h4 class="mb-3">Meses Recientes</h4>
</div>
@forelse($recentMonths as $month)
<div class="col-md-4 col-lg-2 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h6>{{ $month->name }}</h6>
<small class="text-muted">{{ $month->year }}</small>
<div class="mt-2">
@if($month->status === 'open')
<span class="badge bg-success">Abierto</span>
@elseif($month->status === 'closed')
<span class="badge bg-warning">Cerrado</span>
@else
<span class="badge bg-info">Pagado</span>
@endif
</div>
</div>
<div class="card-footer bg-transparent">
<a href="{{ route('months.show', $month->id) }}" class="btn btn-sm btn-outline-primary w-100">Ver</a>
</div>
</div>
</div>
@empty
<div class="col-12">
<div class="alert alert-info">No hay meses registrados.</div>
</div>
@endforelse
</div>
<!-- Acceso rápido -->
<div class="row mt-4">
<div class="col-12">
<h4 class="mb-3">Acceso Rápido</h4>
</div>
<div class="col-md-3">
<a href="{{ route('sales.create') }}" class="btn btn-success w-100 mb-2">
<i class="bi bi-plus-circle"></i> Nueva Venta
</a>
</div>
<div class="col-md-3">
<a href="{{ route('expenses.create') }}" class="btn btn-warning w-100 mb-2">
<i class="bi bi-plus-circle"></i> Nuevo Gasto
</a>
</div>
<div class="col-md-3">
<a href="{{ route('calendar') }}" class="btn btn-info w-100 mb-2">
<i class="bi bi-calendar3"></i> Ver Calendario
</a>
</div>
<div class="col-md-3">
<a href="{{ route('reports.monthly') }}" class="btn btn-secondary w-100 mb-2">
<i class="bi bi-graph-up"></i> Ver Reportes
</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,63 @@
@extends('layouts.app')
@section('title', 'Nuevo Gasto')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Nuevo Gasto</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('expenses.store') }}">
@csrf
<input type="hidden" name="month_id" value="{{ $month->id }}">
<div class="mb-3">
<label for="description" class="form-label">Descripción</label>
<input type="text" class="form-control" id="description" name="description"
value="{{ old('description') }}" required>
</div>
<div class="mb-3">
<label for="amount" class="form-label">Monto</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="amount" name="amount"
value="{{ old('amount') }}" step="0.01" min="0.01" required>
</div>
</div>
<div class="mb-3">
<label for="date" class="form-label">Fecha</label>
<input type="date" class="form-control" id="date" name="date"
value="{{ old('date', now()->format('Y-m-d')) }}" required>
</div>
<div class="mb-3">
<label for="expense_type" class="form-label">Quincena</label>
<select class="form-select" id="expense_type" name="expense_type" required>
<option value="q1">1ra Quincena (1-15)</option>
<option value="q2">2da Quincena (16-31)</option>
<option value="mensual">Gasto Mensual (se divide en 2)</option>
</select>
<small class="text-muted">
Selecciona a qué quincena se restará el gasto.
Los gastos mensuales se dividen entre 2.
</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Guardar Gasto</button>
<a href="{{ route('expenses.index', ['month_id' => $month->id]) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,59 @@
@extends('layouts.app')
@section('title', 'Editar Gasto')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Editar Gasto</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('expenses.update', $expense->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="description" class="form-label">Descripción</label>
<input type="text" class="form-control" id="description" name="description"
value="{{ old('description', $expense->description) }}" required>
</div>
<div class="mb-3">
<label for="amount" class="form-label">Monto</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="amount" name="amount"
value="{{ old('amount', $expense->amount) }}" step="0.01" min="0.01" required>
</div>
</div>
<div class="mb-3">
<label for="date" class="form-label">Fecha</label>
<input type="date" class="form-control" id="date" name="date"
value="{{ old('date', $expense->date->format('Y-m-d')) }}" required>
</div>
<div class="mb-3">
<label for="expense_type" class="form-label">Quincena</label>
<select class="form-select" id="expense_type" name="expense_type" required>
<option value="q1" {{ $expense->expense_type == 'q1' ? 'selected' : '' }}>1ra Quincena (1-15)</option>
<option value="q2" {{ $expense->expense_type == 'q2' ? 'selected' : '' }}>2da Quincena (16-31)</option>
<option value="mensual" {{ $expense->expense_type == 'mensual' ? 'selected' : '' }}>Gasto Mensual (se divide en 2)</option>
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Actualizar</button>
<a href="{{ route('expenses.index', ['month_id' => $expense->month_id]) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,99 @@
@extends('layouts.app')
@section('title', 'Gastos')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Gestión de Gastos</h2>
</div>
</div>
<!-- Selector de mes -->
<div class="row mb-4">
<div class="col-md-6">
<form method="GET" class="d-flex gap-2">
<select name="month_id" class="form-select" onchange="this.form.submit()">
<option value="">Todos los meses</option>
@foreach($months as $m)
<option value="{{ $m->id }}" {{ $month && $month->id == $m->id ? 'selected' : '' }}>
{{ $m->name }} {{ $m->year }}
</option>
@endforeach
</select>
<a href="{{ route('expenses.create', ['month_id' => $month?->id]) }}" class="btn btn-warning">
<i class="bi bi-plus-circle"></i> Nuevo Gasto
</a>
</form>
</div>
</div>
@if($month)
<!-- Resumen -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Total Gastos del Mes</h6>
<h3>${{ number_format($month->expenses()->sum('amount'), 2) }}</h3>
</div>
</div>
</div>
</div>
@endif
<!-- Lista de gastos -->
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th>Monto</th>
<th>Quincena</th>
<th>Mes</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse($expenses as $expense)
<tr>
<td>{{ $expense->date->format('d/m/Y') }}</td>
<td>{{ $expense->description }}</td>
<td class="text-danger">-${{ number_format($expense->amount, 2) }}</td>
<td>
@if($expense->expense_type == 'q1')
<span class="badge bg-primary">Q1</span>
@elseif($expense->expense_type == 'q2')
<span class="badge bg-warning">Q2</span>
@else
<span class="badge bg-info">Mensual</span>
@endif
</td>
<td>{{ $expense->month->name }} {{ $expense->month->year }}</td>
<td>
<a href="{{ route('expenses.edit', $expense->id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ route('expenses.destroy', $expense->id) }}" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar este gasto?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted">No hay gastos registrados.</td>
</tr>
@endforelse
</tbody>
</table>
{{ $expenses->links() }}
</div>
</div>
@endsection

View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'Nómina Pegaso')</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link href='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/main.min.css' rel='stylesheet' />
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f8f9fa;
}
/* Sidebar Styles */
.sidebar {
min-height: 100vh;
background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%);
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
}
.sidebar-brand {
padding: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar a {
color: rgba(255,255,255,0.8);
text-decoration: none;
padding: 12px 20px;
display: block;
border-radius: 8px;
margin: 4px 12px;
transition: all 0.3s ease;
}
.sidebar a:hover {
background: rgba(255,255,255,0.1);
color: #fff;
transform: translateX(5px);
}
.sidebar a.active {
background: #3498db;
color: #fff;
}
.sidebar a i {
width: 24px;
}
/* Card Styles */
.card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.stat-card {
border-left: 4px solid;
border-radius: 8px;
}
.stat-card.primary { border-color: #3498db; }
.stat-card.success { border-color: #27ae60; }
.stat-card.warning { border-color: #f39c12; }
.stat-card.danger { border-color: #e74c3c; }
/* Calendar Styles */
.fc .fc-daygrid-day:hover {
background: #f8f9fa;
cursor: pointer;
}
.fc-event {
cursor: pointer;
border: none;
}
.day-with-sales {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%) !important;
}
.day-with-expenses {
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%) !important;
}
.day-empty {
background: #f8f9fa;
color: #6c757d;
}
/* Mobile Styles */
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: -100%;
width: 280px;
z-index: 1050;
transition: left 0.3s ease;
}
.sidebar.show {
left: 0;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1040;
display: none;
}
.sidebar-overlay.show {
display: block;
}
.mobile-nav-btn {
display: block !important;
}
}
.mobile-nav-btn {
display: none;
position: fixed;
bottom: 20px;
left: 20px;
z-index: 1030;
width: 56px;
height: 56px;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
/* Button Styles */
.btn {
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
}
/* Form Styles */
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #e0e0e0;
padding: 10px 15px;
}
.form-control:focus, .form-select:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
/* Badge Styles */
.badge {
padding: 6px 12px;
border-radius: 20px;
font-weight: 500;
}
/* Toast Styles */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
}
/* Table Styles */
.table th {
font-weight: 600;
color: #495057;
border-bottom: 2px solid #e9ecef;
}
.table-hover tbody tr:hover {
background: #f8f9fa;
}
</style>
@stack('styles')
</head>
<body>
@auth
<!-- Sidebar Overlay (Mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Mobile Navigation Button -->
<button class="btn btn-primary mobile-nav-btn" id="mobileNavBtn">
<i class="bi bi-list"></i>
</button>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-2 d-none d-md-block sidebar py-3" id="sidebar">
<div class="sidebar-brand text-center mb-4">
<h5 class="text-white mb-1">
<i class="bi bi-cash-coin"></i> Nómina Pegaso
</h5>
<small class="text-white-50">{{ auth()->user()->name }}</small>
</div>
<nav>
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">
<i class="bi bi-house-door"></i> Dashboard
</a>
<a href="{{ route('calendar') }}" class="{{ request()->routeIs('calendar*') ? 'active' : '' }}">
<i class="bi bi-calendar3"></i> Calendario
</a>
<a href="{{ route('sales.index') }}" class="{{ request()->routeIs('sales.*') ? 'active' : '' }}">
<i class="bi bi-currency-dollar"></i> Ventas
</a>
<a href="{{ route('expenses.index') }}" class="{{ request()->routeIs('expenses.*') ? 'active' : '' }}">
<i class="bi bi-receipt"></i> Gastos
</a>
<a href="{{ route('months.index') }}" class="{{ request()->routeIs('months.*') ? 'active' : '' }}">
<i class="bi bi-calendar-month"></i> Meses
</a>
<hr class="border-secondary mx-3">
<a href="{{ route('reports.monthly') }}" class="{{ request()->routeIs('reports.*') ? 'active' : '' }}">
<i class="bi bi-graph-up"></i> Reportes
</a>
<a href="{{ route('settings.index') }}" class="{{ request()->routeIs('settings.*') ? 'active' : '' }}">
<i class="bi bi-gear"></i> Configuración
</a>
<a href="{{ route('telegram.verify') }}" class="{{ request()->routeIs('telegram.*') ? 'active' : '' }}">
<i class="bi bi-telegram"></i> Telegram
</a>
<hr class="border-secondary mx-3">
<form method="POST" action="{{ route('logout') }}" class="px-3">
@csrf
<button type="submit" class="btn btn-link text-white w-100 text-start p-2">
<i class="bi bi-box-arrow-right"></i> Cerrar Sesión
</button>
</form>
</nav>
</nav>
<!-- Main content -->
<main class="col-md-10 ms-sm-auto px-4 py-4">
<!-- Mobile Header -->
<div class="d-md-none mb-4">
<div class="card">
<div class="card-body d-flex justify-content-between align-items-center">
<span class="fw-bold">Nómina Pegaso</span>
<button class="btn btn-outline-primary btn-sm" id="mobileNavBtn2">
<i class="bi bi-list"></i>
</button>
</div>
</div>
</div>
@yield('content')
</main>
</div>
</div>
@else
@yield('content')
@endauth
<!-- Toast Container -->
<div class="toast-container">
@if(session('success'))
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header bg-success text-white">
<strong class="me-auto"><i class="bi bi-check-circle"></i> Éxito</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
{{ session('success') }}
</div>
</div>
@endif
@if(session('error'))
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header bg-danger text-white">
<strong class="me-auto"><i class="bi bi-exclamation-circle"></i> Error</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
{{ session('error') }}
</div>
</div>
@endif
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
<script src="{{ asset('js/app.js') }}"></script>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'Nómina Pegaso')</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.auth-card {
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
</style>
@stack('styles')
</head>
<body>
<div class="container">
<div class="row justify-content-center align-items-center min-vh-100">
<div class="col-md-6 col-lg-5">
@yield('content')
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,53 @@
@extends('layouts.app')
@section('title', 'Crear Mes')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Crear Nuevo Mes</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('months.store') }}">
@csrf
<div class="mb-3">
<label for="name" class="form-label">Nombre del Mes</label>
<select class="form-select" id="name" name="name" required>
<option value="">Selecciona un mes</option>
<option value="Enero">Enero</option>
<option value="Febrero">Febrero</option>
<option value="Marzo">Marzo</option>
<option value="Abril">Abril</option>
<option value="Mayo">Mayo</option>
<option value="Junio">Junio</option>
<option value="Julio">Julio</option>
<option value="Agosto">Agosto</option>
<option value="Septiembre">Septiembre</option>
<option value="Octubre">Octubre</option>
<option value="Noviembre">Noviembre</option>
<option value="Diciembre">Diciembre</option>
</select>
</div>
<div class="mb-3">
<label for="year" class="form-label">Año</label>
<input type="number" class="form-control" id="year" name="year"
value="{{ old('year', now()->year) }}" min="2020" max="2100" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">Crear Mes</button>
<a href="{{ route('months.index') }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,53 @@
@extends('layouts.app')
@section('title', 'Editar Mes')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Editar Mes</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('months.update', $month->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="name" class="form-label">Nombre del Mes</label>
<select class="form-select" id="name" name="name" required>
@foreach(['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] as $m)
<option value="{{ $m }}" {{ $month->name == $m ? 'selected' : '' }}>{{ $m }}</option>
@endforeach
</select>
</div>
<div class="mb-3">
<label for="year" class="form-label">Año</label>
<input type="number" class="form-control" id="year" name="year"
value="{{ old('year', $month->year) }}" min="2020" max="2100" required>
</div>
<div class="mb-3">
<label for="status" class="form-label">Estado</label>
<select class="form-select" id="status" name="status" required>
<option value="open" {{ $month->status == 'open' ? 'selected' : '' }}>Abierto</option>
<option value="closed" {{ $month->status == 'closed' ? 'selected' : '' }}>Cerrado</option>
<option value="paid" {{ $month->status == 'paid' ? 'selected' : '' }}>Pagado</option>
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Actualizar</button>
<a href="{{ route('months.show', $month->id) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,58 @@
@extends('layouts.app')
@section('title', 'Meses')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Meses de Trabajo</h2>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<a href="{{ route('months.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Crear Nuevo Mes
</a>
</div>
</div>
<!-- Lista de meses -->
<div class="row">
@forelse($months as $month)
<div class="col-md-4 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">{{ $month->name }} {{ $month->year }}</h5>
</div>
<div class="card-body">
<p class="mb-1">
<strong>Estado:</strong>
@if($month->status === 'open')
<span class="badge bg-success">Abierto</span>
@elseif($month->status === 'closed')
<span class="badge bg-warning">Cerrado</span>
@else
<span class="badge bg-info">Pagado</span>
@endif
</p>
<p class="mb-1"><small class="text-muted">Ventas: ${{ number_format($month->dailySales()->sum('user_sales'), 2) }}</small></p>
<p class="mb-0"><small class="text-muted">Gastos: ${{ number_format($month->expenses()->sum('amount'), 2) }}</small></p>
</div>
<div class="card-footer bg-transparent">
<a href="{{ route('months.show', $month->id) }}" class="btn btn-sm btn-primary">Ver Detalles</a>
</div>
</div>
</div>
@empty
<div class="col-12">
<div class="alert alert-info">
No hay meses registrados.
<a href="{{ route('months.create') }}">Crea tu primer mes</a>
</div>
</div>
@endforelse
</div>
{{ $months->links() }}
@endsection

View File

@@ -0,0 +1,142 @@
@extends('layouts.app')
@section('title', 'Detalles del Mes')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">
{{ $month->name }} {{ $month->year }}
@if($month->status === 'open')
<span class="badge bg-success">Abierto</span>
@elseif($month->status === 'closed')
<span class="badge bg-warning">Cerrado</span>
@else
<span class="badge bg-info">Pagado</span>
@endif
</h2>
</div>
</div>
<!-- Resumen -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card primary">
<div class="card-body">
<h6>Ventas Usuario</h6>
<h4>${{ number_format($month->dailySales()->sum('user_sales'), 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card success">
<div class="card-body">
<h6>Ventas Sistema</h6>
<h4>${{ number_format($month->dailySales()->sum('system_sales'), 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card warning">
<div class="card-body">
<h6>Total Gastos</h6>
<h4>${{ number_format($month->expenses()->sum('amount'), 2) }}</h4>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card danger">
<div class="card-body">
<h6>Días Registrados</h6>
<h4>{{ $month->dailySales()->count() }}</h4>
</div>
</div>
</div>
</div>
<!-- Acciones -->
<div class="row mb-4">
<div class="col-md-12">
<a href="{{ route('sales.create', ['month_id' => $month->id]) }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Agregar Venta
</a>
<a href="{{ route('expenses.create', ['month_id' => $month->id]) }}" class="btn btn-warning">
<i class="bi bi-plus-circle"></i> Agregar Gasto
</a>
<a href="{{ route('reports.monthly', ['month_id' => $month->id]) }}" class="btn btn-info">
<i class="bi bi-graph-up"></i> Ver Reporte
</a>
@if($month->status === 'open')
<form method="POST" action="{{ route('months.close', $month->id) }}" class="d-inline">
@csrf
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('¿Cerrar este mes?')">
<i class="bi bi-lock"></i> Cerrar Mes
</button>
</form>
@endif
</div>
</div>
<!-- Últimas ventas -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Últimas Ventas</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Fecha</th>
<th>Venta Usuario</th>
<th>Venta Sistema</th>
</tr>
</thead>
<tbody>
@forelse($month->dailySales()->latest()->limit(10)->get() as $sale)
<tr>
<td>{{ $sale->date->format('d/m/Y') }}</td>
<td>${{ number_format($sale->user_sales, 2) }}</td>
<td>${{ number_format($sale->system_sales, 2) }}</td>
</tr>
@empty
<tr><td colspan="3" class="text-center">No hay ventas</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- Últimos gastos -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Últimos Gastos</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th>Monto</th>
</tr>
</thead>
<tbody>
@forelse($month->expenses()->latest()->limit(10)->get() as $expense)
<tr>
<td>{{ $expense->date->format('d/m/Y') }}</td>
<td>{{ $expense->description }}</td>
<td class="text-danger">${{ number_format($expense->amount, 2) }}</td>
</tr>
@empty
<tr><td colspan="3" class="text-center">No hay gastos</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-3">
<a href="{{ route('months.index') }}" class="btn btn-secondary">Volver a Meses</a>
</div>
@endsection

View File

@@ -0,0 +1,131 @@
@extends('layouts.app')
@section('title', 'Reporte Quincenal')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Reporte Quincenal</h2>
</div>
</div>
<!-- Selector -->
<div class="row mb-4">
<div class="col-md-4">
<form method="GET" class="d-flex gap-2">
<select name="month_id" class="form-select" onchange="this.form.submit()">
@foreach($months as $m)
<option value="{{ $m->id }}" {{ $month->id == $m->id ? 'selected' : '' }}>
{{ $m->name }} {{ $m->year }}
</option>
@endforeach
</select>
</form>
</div>
<div class="col-md-4">
<form method="GET" class="d-flex gap-2">
<input type="hidden" name="month_id" value="{{ $month->id }}">
<select name="biweekly" class="form-select" onchange="this.form.submit()">
<option value="1" {{ $biweekly == 1 ? 'selected' : '' }}>1ra Quincena (1-15) - ANTICIPO</option>
<option value="2" {{ $biweekly == 2 ? 'selected' : '' }}>2da Quincena (16-31) - LIQUIDACIÓN</option>
</select>
</form>
</div>
<div class="col-md-4 text-end">
<a href="{{ route('reports.monthly', ['month_id' => $month->id]) }}" class="btn btn-outline-primary">Ver Mensual</a>
</div>
</div>
@if($report)
<!-- Resumen Quincenal -->
<div class="row mb-4">
<div class="col-12">
<div class="card {{ $report['type'] === 'anticipo' ? 'border-success' : 'border-warning' }}">
<div class="card-header {{ $report['type'] === 'anticipo' ? 'bg-success' : 'bg-warning' }} text-white">
<h5 class="mb-0">{{ $report['period'] }}</h5>
<small>{{ $report['description'] }}</small>
</div>
<div class="card-body">
<div class="row text-center">
@if($report['type'] === 'anticipo')
<div class="col-md-4">
<h6 class="text-muted">Mitad Sueldo</h6>
<h3>${{ number_format($report['biweekly_salary'], 2) }}</h3>
</div>
<div class="col-md-4">
<h6 class="text-muted">Comisiones del Mes</h6>
<h3 class="text-success">+${{ number_format($report['commission_amount'], 2) }}</h3>
</div>
<div class="col-md-4">
<h6 class="text-muted">Total ANTICIPO</h6>
<h2 class="text-success">${{ number_format($report['total_earning'], 2) }}</h2>
</div>
@else
<div class="col-md-4">
<h6 class="text-muted">Mitad Sueldo</h6>
<h3>${{ number_format($report['biweekly_salary'], 2) }}</h3>
</div>
<div class="col-md-4">
<h6 class="text-muted">Gastos Q{{ $biweekly }}</h6>
<h3 class="text-danger">-${{ number_format($report['expenses_q2'], 2) }}</h3>
</div>
<div class="col-md-4">
<h6 class="text-muted">Total LIQUIDACIÓN</h6>
<h2 class="{{ $report['total_earning'] >= 0 ? 'text-success' : 'text-danger' }}">${{ number_format($report['total_earning'], 2) }}</h2>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
<!-- Desglose de gastos -->
@if(count($expenses) > 0)
<div class="card mb-4">
<div class="card-header">
<h5>Gastos de la Quincena</h5>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th>Tipo</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@foreach($expenses as $expense)
<tr>
<td>{{ $expense->date->format('d/m/Y') }}</td>
<td>{{ $expense->description }}</td>
<td>
@if($expense->expense_type == 'q1')
<span class="badge bg-primary">Q1</span>
@elseif($expense->expense_type == 'q2')
<span class="badge bg-warning">Q2</span>
@else
<span class="badge bg-info">Mensual (mitad)</span>
@endif
</td>
<td class="text-end text-danger">${{ number_format($expense->expense_type == 'mensual' ? $expense->amount / 2 : $expense->amount, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@else
<div class="alert alert-warning">No hay datos para mostrar.</div>
@endif
<div class="row mt-4">
<div class="col-12">
<a href="{{ route('dashboard') }}" class="btn btn-secondary"> Volver al Dashboard</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,199 @@
@extends('layouts.app')
@section('title', 'Reporte Mensual')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Reporte Mensual</h2>
</div>
</div>
<!-- Selector -->
<div class="row mb-4">
<div class="col-md-6">
<form method="GET" class="d-flex gap-2">
<select name="month_id" class="form-select" onchange="this.form.submit()">
@foreach($months as $m)
<option value="{{ $m->id }}" {{ $month->id == $m->id ? 'selected' : '' }}>
{{ $m->name }} {{ $m->year }}
</option>
@endforeach
</select>
<a href="{{ route('reports.biweekly', ['month_id' => $month->id, 'biweekly' => 1]) }}" class="btn btn-outline-primary">Quincenal</a>
</form>
</div>
</div>
@if($report)
<!-- Resumen General -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">{{ $report['month_name'] }}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<h6 class="text-muted">Ventas del Usuario</h6>
<h4>${{ number_format($report['total_user_sales'], 2) }}</h4>
</div>
<div class="col-md-3">
<h6 class="text-muted">Ventas del Sistema</h6>
<h4>${{ number_format($report['total_system_sales'], 2) }}</h4>
</div>
<div class="col-md-3">
<h6 class="text-muted">Comisión ({{ $report['commission_percentage'] }}%)</h6>
<h4 class="text-success">${{ number_format($report['commission_amount'], 2) }}</h4>
</div>
<div class="col-md-3">
<h6 class="text-muted">Total a Recibir</h6>
<h4 class="text-primary">${{ number_format($report['total_earning'], 2) }}</h4>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Detalles -->
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Desglose</h5>
</div>
<div class="card-body">
<table class="table">
<tr>
<td>Salario Mensual</td>
<td class="text-end">${{ number_format($report['monthly_salary'], 2) }}</td>
</tr>
<tr>
<td>Comisión ({{ $report['commission_percentage'] }}%)</td>
<td class="text-end text-success">+${{ number_format($report['commission_amount'], 2) }}</td>
</tr>
<tr>
<td>Gastos del Mes</td>
<td class="text-end text-danger">-${{ number_format($report['total_expenses'], 2) }}</td>
</tr>
<tr class="table-light">
<td><strong>Total a Recibir</strong></td>
<td class="text-end"><strong>${{ number_format($report['total_earning'], 2) }}</strong></td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Diferencia de Ventas</h5>
</div>
<div class="card-body">
@if($report['has_difference'])
<div class="alert alert-{{ $report['sales_difference'] > 0 ? 'warning' : 'danger' }}">
<strong>Diferencia:</strong> ${{ number_format($report['sales_difference'], 2) }}
<br>
<small>
{{ $report['sales_difference'] > 0 ? 'Ventas del usuario mayores que sistema' : 'Ventas del sistema mayores que usuario' }}
</small>
</div>
@else
<div class="alert alert-success">
<i class="bi bi-check-circle"></i> Ventas conciliadas (sin diferencia)
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Ventas Diarias -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Ventas Diarias</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Fecha</th>
<th class="text-end">Venta Usuario</th>
<th class="text-end">Venta Sistema</th>
<th class="text-end">Diferencia</th>
</tr>
</thead>
<tbody>
@forelse($dailySales as $sale)
<tr>
<td>{{ $sale->date->format('d/m/Y') }}</td>
<td class="text-end">${{ number_format($sale->user_sales, 2) }}</td>
<td class="text-end">${{ number_format($sale->system_sales, 2) }}</td>
<td class="text-end">
@php $diff = $sale->user_sales - $sale->system_sales; @endphp
@if($diff != 0)
<span class="text-{{ $diff > 0 ? 'warning' : 'danger' }}">
${{ number_format($diff, 2) }}
</span>
@else
<span class="text-success">-</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="4" class="text-center">No hay ventas</td></tr>
@endforelse
</tbody>
<tfoot>
<tr class="table-light">
<th>Total</th>
<th class="text-end">${{ number_format($dailySales->sum('user_sales'), 2) }}</th>
<th class="text-end">${{ number_format($dailySales->sum('system_sales'), 2) }}</th>
<th class="text-end">${{ number_format($dailySales->sum('user_sales') - $dailySales->sum('system_sales'), 2) }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Gastos -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Gastos del Mes</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@forelse($expenses as $expense)
<tr>
<td>{{ $expense->date->format('d/m/Y') }}</td>
<td>{{ $expense->description }}</td>
<td class="text-end text-danger">${{ number_format($expense->amount, 2) }}</td>
</tr>
@empty
<tr><td colspan="3" class="text-center">No hay gastos</td></tr>
@endforelse
</tbody>
<tfoot>
<tr class="table-light">
<th colspan="2">Total Gastos</th>
<th class="text-end">${{ number_format($expenses->sum('amount'), 2) }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
@else
<div class="alert alert-warning">No hay datos disponibles para este mes.</div>
@endif
@endsection

View File

@@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', 'Reporte Anual')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Reporte Anual</h2>
</div>
</div>
<!-- Selector de año -->
<div class="row mb-4">
<div class="col-md-4">
<form method="GET" class="d-flex gap-2">
<select name="year" class="form-select" onchange="this.form.submit()">
@foreach($years as $y)
<option value="{{ $y }}" {{ $year == $y ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
</form>
</div>
</div>
@if($report)
<!-- Resumen Anual -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">Resumen del Año {{ $year }}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-2">
<h6 class="text-muted">Meses</h6>
<h4>{{ $report['months_count'] }}</h4>
</div>
<div class="col-md-2">
<h6 class="text-muted">Ventas Usuario</h6>
<h4>${{ number_format($report['total_user_sales'], 0) }}</h4>
</div>
<div class="col-md-2">
<h6 class="text-muted">Ventas Sistema</h6>
<h4>${{ number_format($report['total_system_sales'], 0) }}</h4>
</div>
<div class="col-md-2">
<h6 class="text-muted">Total Salario</h6>
<h4>${{ number_format($report['total_salary'], 2) }}</h4>
</div>
<div class="col-md-2">
<h6 class="text-muted">Total Comisión</h6>
<h4 class="text-success">${{ number_format($report['total_commission'], 2) }}</h4>
</div>
<div class="col-md-2">
<h6 class="text-muted">Total Año</h6>
<h4 class="text-primary">${{ number_format($report['total_earning'], 2) }}</h4>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Meses del año -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Detalle por Mes</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Mes</th>
<th class="text-end">Ventas Usuario</th>
<th class="text-end">Ventas Sistema</th>
<th class="text-end">Gastos</th>
<th class="text-end">Comisión</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
@forelse($months as $month)
@php
$userSales = $month->dailySales()->sum('user_sales');
$systemSales = $month->dailySales()->sum('system_sales');
$expenses = $month->expenses()->sum('amount');
$commission = ($systemSales * auth()->user()->commission_percentage) / 100;
$total = auth()->user()->monthly_salary + $commission - $expenses;
@endphp
<tr>
<td>{{ $month->name }}</td>
<td class="text-end">${{ number_format($userSales, 2) }}</td>
<td class="text-end">${{ number_format($systemSales, 2) }}</td>
<td class="text-end text-danger">${{ number_format($expenses, 2) }}</td>
<td class="text-end text-success">${{ number_format($commission, 2) }}</td>
<td class="text-end"><strong>${{ number_format($total, 2) }}</strong></td>
</tr>
@empty
<tr><td colspan="6" class="text-center">No hay meses en este año</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@else
<div class="alert alert-warning">No hay datos disponibles para este año.</div>
@endif
@endsection

View File

@@ -0,0 +1,54 @@
@extends('layouts.app')
@section('title', 'Nueva Venta')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Nueva Venta</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('sales.store') }}">
@csrf
<input type="hidden" name="month_id" value="{{ $month->id }}">
<div class="mb-3">
<label for="date" class="form-label">Fecha</label>
<input type="date" class="form-control" id="date" name="date"
value="{{ old('date', now()->format('Y-m-d')) }}" required>
</div>
<div class="mb-3">
<label for="user_sales" class="form-label">Ventas del Usuario</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="user_sales" name="user_sales"
value="{{ old('user_sales') }}" step="0.01" min="0" required>
</div>
</div>
<div class="mb-3">
<label for="system_sales" class="form-label">Ventas del Sistema (Opcional)</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="system_sales" name="system_sales"
value="{{ old('system_sales', 0) }}" step="0.01" min="0">
</div>
<small class="text-muted">Ventas consolidadas del sistema</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">Guardar Venta</button>
<a href="{{ route('sales.index', ['month_id' => $month->id]) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,53 @@
@extends('layouts.app')
@section('title', 'Editar Venta')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Editar Venta</h2>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('sales.update', $sale->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="date" class="form-label">Fecha</label>
<input type="date" class="form-control" id="date" name="date"
value="{{ old('date', $sale->date->format('Y-m-d')) }}" required>
</div>
<div class="mb-3">
<label for="user_sales" class="form-label">Ventas del Usuario</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="user_sales" name="user_sales"
value="{{ old('user_sales', $sale->user_sales) }}" step="0.01" min="0" required>
</div>
</div>
<div class="mb-3">
<label for="system_sales" class="form-label">Ventas del Sistema</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="system_sales" name="system_sales"
value="{{ old('system_sales', $sale->system_sales) }}" step="0.01" min="0">
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Actualizar</button>
<a href="{{ route('sales.index', ['month_id' => $sale->month_id]) }}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', 'Ventas')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Gestión de Ventas</h2>
</div>
</div>
<!-- Selector de mes -->
<div class="row mb-4">
<div class="col-md-6">
<form method="GET" class="d-flex gap-2">
<select name="month_id" class="form-select" onchange="this.form.submit()">
@foreach($months as $m)
<option value="{{ $m->id }}" {{ $month->id == $m->id ? 'selected' : '' }}>
{{ $m->name }} {{ $m->year }} ({{ $m->status }})
</option>
@endforeach
</select>
<a href="{{ route('sales.create', ['month_id' => $month->id]) }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Nueva Venta
</a>
</form>
</div>
<div class="col-md-6 text-end">
<div class="btn-group">
<a href="{{ route('sales.index', ['month_id' => $month->id]) }}" class="btn btn-outline-secondary">Todas</a>
<a href="{{ route('expenses.index', ['month_id' => $month->id]) }}" class="btn btn-outline-warning">Ver Gastos</a>
</div>
</div>
</div>
<!-- Resumen -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Total Ventas Usuario</h6>
<h3>${{ number_format($month->dailySales()->sum('user_sales'), 2) }}</h3>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Total Ventas Sistema</h6>
<h3>${{ number_format($month->dailySales()->sum('system_sales'), 2) }}</h3>
</div>
</div>
</div>
</div>
<!-- Lista de ventas -->
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Fecha</th>
<th>Venta Usuario</th>
<th>Venta Sistema</th>
<th>Diferencia</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse($sales as $sale)
<tr>
<td>{{ $sale->date->format('d/m/Y') }}</td>
<td>${{ number_format($sale->user_sales, 2) }}</td>
<td>${{ number_format($sale->system_sales, 2) }}</td>
<td>
@php $diff = $sale->user_sales - $sale->system_sales; @endphp
@if($diff != 0)
<span class="badge bg-{{ $diff > 0 ? 'warning' : 'danger' }}">
${{ number_format($diff, 2) }}
</span>
@else
<span class="badge bg-success">conciliada</span>
@endif
</td>
<td>
<a href="{{ route('sales.edit', $sale->id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ route('sales.destroy', $sale->id) }}" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta venta?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted">No hay ventas registradas.</td>
</tr>
@endforelse
</tbody>
</table>
{{ $sales->links() }}
</div>
</div>
@endsection

View File

@@ -0,0 +1,257 @@
@extends('layouts.app')
@section('title', 'Configuración')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">
<i class="bi bi-gear text-primary"></i> Configuración
</h2>
</div>
</div>
<div class="row">
<div class="col-md-8">
<!-- Datos Laborales -->
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-briefcase"></i> Datos Laborales
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ route('settings.update') }}">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="fecha_ingreso" class="form-label">Fecha de Ingreso</label>
<input type="date" class="form-control" id="fecha_ingreso"
name="fecha_ingreso"
value="{{ old('fecha_ingreso', auth()->user()->fecha_ingreso?->format('Y-m-d')) }}">
<small class="text-muted">Fecha en que started a trabajar en la empresa</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="razon_social" class="form-label">Razón Social</label>
<input type="text" class="form-control" id="razon_social"
name="razon_social"
value="{{ old('razon_social', auth()->user()->razon_social) }}"
placeholder="Empresa donde trabajas">
<small class="text-muted">Nombre de la empresa o negocio</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="sueldo_integro_diario" class="form-label">Sueldo Íntegro Diario</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="sueldo_integro_diario"
name="sueldo_integro_diario"
value="{{ old('sueldo_integro_diario', auth()->user()->sueldo_integro_diario) }}"
min="0" step="0.01">
</div>
<small class="text-muted">Salario diario integrado (para calcular vacaciones)</small>
</div>
</div>
</div>
@php($user = auth()->user())
@if($user->fecha_ingreso)
<div class="alert alert-info">
<i class="bi bi-calendar-check"></i>
<strong>Antigüedad:</strong> {{ $user->fecha_ingreso->diffInYears(now()) }} año(s) y {{ $user->fecha_ingreso->diffInMonths(now()) % 12 }} mes(es)
</div>
@endif
<button type="submit" class="btn btn-success">
<i class="bi bi-save"></i> Guardar Datos Laborales
</button>
</form>
</div>
</div>
<!-- Configuración de Comisión -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="bi bi-percent"></i> Configuración de Comisión
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ route('settings.update') }}">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="commission_percentage" class="form-label">Porcentaje de Comisión (%)</label>
<div class="input-group">
<input type="number" class="form-control" id="commission_percentage"
name="commission_percentage"
value="{{ old('commission_percentage', auth()->user()->commission_percentage) }}"
min="0" max="100" step="0.01" required>
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Porcentaje que recibirás sobre tus ventas</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="monthly_salary" class="form-label">Salario Mensual Base</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="monthly_salary"
name="monthly_salary"
value="{{ old('monthly_salary', auth()->user()->monthly_salary) }}"
min="0" step="0.01" required>
</div>
<small class="text-muted">Salario base mensual sin comisiones</small>
</div>
</div>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Nota:</strong> Estos valores se aplicarán a partir del próximo mes.
Los meses actuales mantendrán su configuración original.
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Guardar Cambios
</button>
</form>
</div>
</div>
<!-- Cambiar Contraseña -->
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">
<i class="bi bi-key"></i> Cambiar Contraseña
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ route('settings.update') }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="current_password" class="form-label">Contraseña Actual</label>
<input type="password" class="form-control" id="current_password"
name="current_password">
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="password" class="form-label">Nueva Contraseña</label>
<input type="password" class="form-control" id="password"
name="password">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="password_confirmation" class="form-label">Confirmar Contraseña</label>
<input type="password" class="form-control" id="password_confirmation"
name="password_confirmation">
</div>
</div>
</div>
<small class="text-muted">Deja los campos de contraseña en blanco si no deseas cambiarlo.</small>
<div class="mt-3">
<button type="submit" class="btn btn-secondary">
<i class="bi bi-key-fill"></i> Actualizar Contraseña
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Información del Usuario -->
<div class="card mb-4">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">
<i class="bi bi-person"></i> Información del Usuario
</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">Nombre:</dt>
<dd class="col-sm-8">{{ auth()->user()->name }}</dd>
<dt class="col-sm-4">Email:</dt>
<dd class="col-sm-8">{{ auth()->user()->email }}</dd>
@if(auth()->user()->razon_social)
<dt class="col-sm-4">Empresa:</dt>
<dd class="col-sm-8">{{ auth()->user()->razon_social }}</dd>
@endif
@if(auth()->user()->fecha_ingreso)
<dt class="col-sm-4">Ingreso:</dt>
<dd class="col-sm-8">{{ auth()->user()->fecha_ingreso->format('d/m/Y') }}</dd>
@endif
<dt class="col-sm-4">Comisión:</dt>
<dd class="col-sm-8">
<span class="badge bg-primary">{{ auth()->user()->commission_percentage }}%</span>
</dd>
<dt class="col-sm-4">Salario:</dt>
<dd class="col-sm-8">${{ number_format(auth()->user()->monthly_salary, 2) }}</dd>
<dt class="col-sm-4">Estado:</dt>
<dd class="col-sm-8">
@if(auth()->user()->is_active)
<span class="badge bg-success">Activo</span>
@else
<span class="badge bg-danger">Inactivo</span>
@endif
</dd>
</dl>
</div>
</div>
<!-- Ayuda -->
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-question-circle"></i> Ayuda
</h5>
</div>
<div class="card-body">
<h6>¿Cómo funciona la comisión?</h6>
<p class="text-muted small">
Tu salario se calcula sumando el salary base más el {{ auth()->user()->commission_percentage }}%
de tus ventas del mes.
</p>
<h6 class="mt-3">¿Qué son las ventas del sistema?</h6>
<p class="text-muted small">
Las ventas del sistema son las ventas consolidadas automáticamente.
Si difieren de tus ventas, aparecerán en el reporte.
</p>
<h6 class="mt-3">¿Puedo cambiar estos valores?</h6>
<p class="text-muted small">
, pero los cambios se aplicarán al siguiente mes.
Los meses actuales mantendrán su configuración.
</p>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,78 @@
@extends('layouts.app')
@section('title', 'Vincular Telegram')
@section('content')
<div class="row">
<div class="col-12">
<h2 class="mb-4">Vincular Cuenta de Telegram</h2>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
@if($telegramAccount->is_verified)
<div class="alert alert-success">
<h5><i class="bi bi-check-circle"></i> Cuenta Verificada</h5>
<p>Tu cuenta de Telegram está vinculada correctamente.</p>
</div>
<div class="mb-4">
<strong>Chat ID:</strong> {{ $telegramAccount->chat_id }}
</div>
<form method="POST" action="{{ route('telegram.unlink') }}">
@csrf
<button type="submit" class="btn btn-danger" onclick="return confirm('¿Desvincular cuenta de Telegram?')">
<i class="bi bi-unlink"></i> Desvincular Cuenta
</button>
</form>
@else
<div class="alert alert-info">
<h5><i class="bi bi-info-circle"></i> Vinculación de Telegram</h5>
<p>Sigue estos pasos para vincular tu cuenta de Telegram:</p>
<ol>
<li>Abre Telegram y busca el bot de Nómina Pegaso</li>
<li>Envía el código de verificación que aparece abajo</li>
<li>Recibirás una confirmación cuando esté vinculado</li>
</ol>
</div>
<div class="card bg-light mb-4">
<div class="card-body text-center">
<h6 class="text-muted">Tu Código de Verificación</h6>
<h1 class="display-4 text-primary">{{ $telegramAccount->verification_code }}</h1>
<small class="text-muted">Este código expira cuando se genera uno nuevo</small>
</div>
</div>
<form method="POST" action="{{ route('telegram.regenerate') }}">
@csrf
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-arrow-clockwise"></i> Regenerar Código
</button>
</form>
@endif
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Información</h5>
</div>
<div class="card-body">
<p class="small">Al vincular tu cuenta de Telegram podrás:</p>
<ul class="small">
<li>Recibir notificaciones de tus ventas</li>
<li>Consultar tu estado de comisiones</li>
<li>Recibir recordatorios de registro</li>
</ul>
</div>
</div>
</div>
</div>
@endsection

225
resources/views/welcome.blade.php Executable file

File diff suppressed because one or more lines are too long

8
routes/console.php Executable file
View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

21
routes/test.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use App\Models\User;
Route::post('/test-save', function (Request $request) {
$user = User::find(1);
$user->fecha_ingreso = $request->fecha_ingreso;
$user->razon_social = $request->razon_social;
$user->save();
return response()->json([
'success' => true,
'message' => 'Saved!',
'data' => [
'fecha_ingreso' => $user->fecha_ingreso,
'razon_social' => $user->razon_social
]
]);
});

71
routes/web.php Executable file
View File

@@ -0,0 +1,71 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ExpenseController;
use App\Http\Controllers\MonthController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\SaleController;
use App\Http\Controllers\SettingsController;
use App\Http\Controllers\TelegramController;
use Illuminate\Support\Facades\Route;
// Rutas públicas - Auth
Route::middleware('guest')->group(function () {
Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
Route::get('/register', [AuthController::class, 'showRegisterForm'])->name('register');
Route::post('/register', [AuthController::class, 'register']);
});
// Logout (accessible to authenticated users)
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
// Rutas protegidas
Route::middleware(['auth'])->group(function () {
// Dashboard
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// Calendario
Route::get('/calendar', [CalendarController::class, 'index'])->name('calendar');
Route::post('/calendar/day', [CalendarController::class, 'storeDay'])->name('calendar.day.store');
// Meses
Route::resource('months', MonthController::class);
Route::post('/months/{month}/close', [MonthController::class, 'close'])->name('months.close');
// Ventas
Route::get('/sales', [SaleController::class, 'index'])->name('sales.index');
Route::get('/sales/create', [SaleController::class, 'create'])->name('sales.create');
Route::post('/sales', [SaleController::class, 'store'])->name('sales.store');
Route::get('/sales/{sale}/edit', [SaleController::class, 'edit'])->name('sales.edit');
Route::put('/sales/{sale}', [SaleController::class, 'update'])->name('sales.update');
Route::delete('/sales/{sale}', [SaleController::class, 'destroy'])->name('sales.destroy');
// Gastos
Route::get('/expenses', [ExpenseController::class, 'index'])->name('expenses.index');
Route::get('/expenses/create', [ExpenseController::class, 'create'])->name('expenses.create');
Route::post('/expenses', [ExpenseController::class, 'store'])->name('expenses.store');
Route::get('/expenses/{expense}/edit', [ExpenseController::class, 'edit'])->name('expenses.edit');
Route::put('/expenses/{expense}', [ExpenseController::class, 'update'])->name('expenses.update');
Route::delete('/expenses/{expense}', [ExpenseController::class, 'destroy'])->name('expenses.destroy');
// Reportes
Route::get('/reports/monthly', [ReportController::class, 'monthly'])->name('reports.monthly');
Route::get('/reports/biweekly', [ReportController::class, 'biweekly'])->name('reports.biweekly');
Route::get('/reports/yearly', [ReportController::class, 'yearly'])->name('reports.yearly');
// Telegram
Route::get('/telegram/verify', [TelegramController::class, 'showVerifyPage'])->name('telegram.verify');
Route::post('/telegram/regenerate', [TelegramController::class, 'regenerateCode'])->name('telegram.regenerate');
Route::post('/telegram/unlink', [TelegramController::class, 'unlink'])->name('telegram.unlink');
// Settings
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('settings.update');
});
// Webhook de Telegram (público, sin auth)
Route::post('/telegram/webhook', [TelegramController::class, 'webhook'])->name('telegram.webhook');
Route::get('/telegram/setup', [TelegramController::class, 'setupWebhook'])->name('telegram.setup');

4
storage/app/.gitignore vendored Executable file
View File

@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

2
storage/app/private/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/app/public/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

9
storage/framework/.gitignore vendored Executable file
View File

@@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

3
storage/framework/cache/.gitignore vendored Executable file
View File

@@ -0,0 +1,3 @@
*
!data/
!.gitignore

2
storage/framework/cache/data/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/sessions/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/testing/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/views/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/logs/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

19
tests/Feature/ExampleTest.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

Some files were not shown because too many files have changed in this diff Show More