Hoy te voy a presentar un software web programado con Laravel (en el lenguaje PHP, usando un poco de JavaScript) y Vue.js que gestiona un inventario de artículos; permite tomar fotos de los mismos, agregar áreas, etcétera.
No es un software terminado ni listo para usarse en producción, de hecho sirve más para aprender sobre los conceptos de Laravel o para tomarlo como punto de partida.
Al final del post dejaré la historia del mismo, que se resume a que era un software que ya no terminé pero que no me gustaría dejarlo en el olvido siendo que puede servirle a alguien más.
Nota: en el post expongo los fragmentos de código más importantes, pero al final del post también dejaré el enlace al repositorio de GitHub en donde puedes explorar o descargar el código como tú gustes.
Diseño general
Estoy utilizando el framework CSS Bulma. Para el lado del cliente utilizo Vue.js. Comencemos viendo la plantilla maestra de Blade:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield("titulo")</title>
<link rel="stylesheet" href="{{url("/css/estilos.css")}}">
<link rel="stylesheet" href="{{url("/css/all.min.css")}}">
<link rel="stylesheet" href="{{url("/css/bulma.min.css")}}"/>
<script type="text/javascript">
const URL_BASE = "{{url("/")}}",
URL_BASE_API = "{{url("/api")}}",
TOKEN_CSRF = "{{csrf_token()}}";
</script>
<script src="{{url("/js/principal.js?q=") . time()}}"></script>
<script src="{{url("/js/wireframe.js?q=") . time()}}"></script>
<script src="{{url("/js/utiles.js")}}"></script>
<script src="{{url("/js/vue.js")}}"></script>
</head>
<body>
@if(Auth::check())
<nav class="navbar is-transparent has-shadow is-spaced">
<div class="navbar-brand">
<a class="navbar-item" href="#">
<img class="logo" style="max-height: 100%;" src="{{url("/img/logo.png") }}"
alt="Aquí el logotipo de la empresa"
width="150" height="20">
</a>
<div id="intercambiarMenu" class="navbar-burger burger" data-target="menuPrincipal">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div id="menuPrincipal" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="{{ route("areas") }}">
<span class="icon has-text-danger">
<i class="fa fa-home"></i>
</span> Áreas
</a>
<a class="navbar-item" href="{{ route("responsables") }}">
<span class="icon has-text-success">
<i class="fa fa-users"></i>
</span> Responsables
</a>
<a class="navbar-item" href="{{ route("articulos") }}">
<span class="icon has-text-info">
<i class="fa fa-box"></i>
</span> Inventario
</a>
<a class="navbar-item" href="#">
<span class="icon has-text-info">
<i class="fa fa-chart-line"></i>
</span> Reportes
</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<div class="field is-grouped">
<a class="button"
href="{{route("logout")}}">
<strong>Salir</strong> ({{Auth::user()->nombre}})
<span class="icon has-text-danger">
<i class="fa fa-sign-out-alt"></i>
</span>
</a>
</div>
</div>
</div>
</div>
</nav>
@endif
<section class="section" style="padding-top: 0.3rem;">
@yield("contenido")
</section>
</body>
</html>
También utilizo los iconos de font awesome. Como ves estoy inyectando todo el contenido dentro de un section
con la clase section
.
Más tarde, gracias al poder de Blade, puedo reutilizar este diseño. Por ejemplo, en áreas:
@extends("maestra")
@section("titulo", "Áreas")
@section("contenido")
<div id="app" class="container" v-cloak>
<div class="columns">
<div class="column">
<div class="notification">
<div class="columns is-vcentered">
<div class="column">
@verbatim
<h4 class="is-size-4">Áreas ({{paginacion.total}})</h4>
@endverbatim
</div>
<div class="column">
<div class="field has-addons">
<div class="control">
<input :readonly="deberiaDeshabilitarBusqueda" v-model="busqueda" @keyup="buscar()"
class="input " type="text"
placeholder="Buscar por nombre">
</div>
<div class="control">
<button :disabled="deberiaDeshabilitarBusqueda || !busqueda" v-show="!this.busqueda"
@click="buscar()" class="button is-info"
:class="{'is-loading': buscando}">
<span class="icon is-small">
<i class="fa fa-search"></i>
</span>
</button>
<button v-show="this.busqueda" @click="limpiarBusqueda()" class="button is-info"
:class="{'is-loading': buscando}">
<span class="icon is-small">
<i class="fa fa-times"></i>
</span>
</button>
</div>
</div>
</div>
<div class="column">
<div class="field is-grouped is-pulled-right">
<div class="control">
<a href="{{route("formularioArea")}}" class="button is-success">Agregar</a>
</div>
<div class="control">
@verbatim
<transition name="bounce">
<button @click="eliminarMarcadas()" v-show="numeroDeElementosMarcados > 0"
class="button is-warning"
:class="{'is-loading': cargando.eliminandoMuchos}">
Eliminar ({{numeroDeElementosMarcados}})
</button>
</transition>
@endverbatim
</div>
</div>
</div>
</div>
</div>
<div v-show="cargando.lista" class="notification is-info has-text-centered">
<h3 class="is-size-3">Cargando</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-spinner fa-spin"></i>
</h1>
</div>
<p class="is-size-5">
Por favor, espera un momento
</p>
</div>
<transition name="fade">
<div v-show="areas.length <= 0 && !busqueda && !cargando.lista"
class="notification is-info has-text-centered">
<h3 class="is-size-3">No hay áreas</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-box-open"></i>
</h1>
</div>
<p class="is-size-5">
Parece que no has agregado ninguna área. Registra una haciendo click en el botón
<strong>Agregar</strong>
</p>
</div>
</transition>
<transition name="fade">
<div v-show="areas.length <= 0 && busqueda && !cargando.lista"
class="notification is-warning has-text-centered">
<h3 class="is-size-3">No hay resultados</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-search"></i>
</h1>
</div>
<p class="is-size-5">
No hay resultados que coincidan con tu búsqueda
</p>
</div>
</transition>
@include("errores")
@include("notificacion")
<div>
<table v-show="areas.length > 0 && !cargando.lista"
class="table is-bordered is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>
<button @click="onBotonParaMarcarClickeado()" class="button"
:class="{'is-info': numeroDeElementosMarcados > 0}">
<span class="icon is-small">
<i class="fa fa-check"></i>
</span>
</button>
</th>
<th>Área</th>
<th>Editar</th>
<th>Eliminar</th>
</tr>
</thead>
<tbody>
@verbatim
<tr v-for="area in areas">
<td>
<button @click="invertirEstado(area)" class="button"
:class="{'is-info': area.marcada}">
<span class="icon is-small">
<i class="fa fa-check"></i>
</span>
</button>
</td>
<td>{{area.nombre}}</td>
<td>
<button @click="editar(area)" class="button is-warning">
<span class="icon is-small">
<i class="fa fa-edit"></i>
</span>
</button>
</td>
<td>
<button @click="eliminar(area)" class="button is-danger"
:class="{'is-loading': area.eliminando}">
<span class="icon is-small">
<i class="fa fa-trash"></i>
</span>
</button>
</td>
</tr>
@endverbatim
</tbody>
</table>
<nav v-show="paginacion.ultima > 1" class="pagination" role="navigation" aria-label="pagination">
<a :disabled="!puedeRetrocederPaginacion()" @click="retrocederPaginacion()"
class="pagination-previous">Anterior</a>
<a :disabled="!puedeAvanzarPaginacion()" @click="avanzarPaginacion()" class="pagination-next">Siguiente
página</a>
@verbatim
<ul class="pagination-list">
<li v-for="pagina in paginas">
<a v-if="!pagina.puntosSuspensivos" @click="irALaPagina(pagina.numero)"
class="pagination-link"
:class="{'is-current':pagina.numero === paginacion.actual}">{{pagina.numero}}</a>
<span class="pagination-ellipsis" v-else>…</span>
</li>
</ul>
@endverbatim
</nav>
</div>
</div>
</div>
</div>
<script src="{{url("/js/areas.js?q=") . time()}}"></script>
@endsection
En el pie de cada plantilla, si es necesario, incluyo el script que se encarga de manejarla. No estoy usando componentes de Vue, sino simples plantillas que renderizan a HTML y que luego se modifican con Vue.
Rutas
Veamos las rutas. Cabe mencionar que la mayoría de rutas son para la API y algunas para las vistas, pues varias cosas se manejan del lado del cliente (estamos consumiendo la API de Laravel con Vue).
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
use Illuminate\Support\Facades\Auth;
Route::get('/', function () {
return redirect()->to("/login");
});
//-------------------------------
// Áreas
//-------------------------------
Route::group(
[
"middleware" => [
"auth"
]
],
function () {
# API
Route::prefix("api")
->group(function () {
// Áreas
Route::get("areas", "AreasController@mostrar");
Route::get("areas/buscar/{busqueda}", "AreasController@buscar");
Route::delete("area/{id}", "AreasController@eliminar");
Route::post("areas/eliminar", "AreasController@eliminarMuchas");
// Responsables
Route::post("/responsable", "ResponsablesController@agregar");
Route::get("responsables", "ResponsablesController@mostrar");
Route::get("responsable/{id}", "ResponsablesController@porId");
Route::get("responsables/buscar/{busqueda}", "ResponsablesController@buscar");
Route::delete("responsable/{id}", "ResponsablesController@eliminar");
Route::post("responsables/eliminar", "ResponsablesController@eliminarMuchos");
Route::put("responsable/", "ResponsablesController@guardarCambios")->name("guardarCambiosDeResponsable");
// Artículos
Route::post("/articulo", "ArticulosController@agregar");
Route::get("/articulos", "ArticulosController@mostrar");
Route::get("articulo/{id}", "ArticulosController@porId");
Route::put("articulo/", "ArticulosController@guardarCambios")->name("guardarCambiosDeResponsable");
// Fotos de artículos
Route::post("eliminar/foto/articulo/", "ArticulosController@eliminarFoto")->name("eliminarFotoDeArticulo");
});
# VISTAS
Route::view("areas/agregar", "agregar_area")->name("formularioArea");
Route::get("areas/editar/{id}", "AreasController@editar")->name("formularioEditarArea");
Route::view("areas/", "areas")->name("areas");
# Otras cosas
Route::post("areas/agregar", "AreasController@agregar")->name("guardarArea");
Route::put("area/", "AreasController@guardarCambios")->name("guardarCambiosDeArea");
Route::get("foto/articulo/{nombre}", "ArticulosController@foto")->name("fotoDeArticulo");
Route::get("descargar/foto/articulo/{nombre}", "ArticulosController@descargar")->name("descargarFotoDeArticulo");
//-------------------------------
// Responsables
//-------------------------------
Route::view("responsables/agregar", "responsables/agregar")->name("formularioAgregarResponsable");
Route::view("responsables/", "responsables/mostrar")->name("responsables");
Route::view("responsables/editar/{id}", "responsables/editar")->name("formularioEditarResponsable");
//-------------------------------
// Artículos
//-------------------------------
Route::view("articulos/agregar", "articulos.agregar")->name("formularioAgregarArticulo");
Route::view("articulos/", "articulos/mostrar")->name("articulos");
Route::view("articulos/editar/{id}", "articulos/editar")->name("formularioEditarArticulo");
Route::get("articulos/fotos/{id}", "ArticulosController@administrarFotos")->name("administrarFotos");
Route::get("articulos/eliminar/{id}", "ArticulosController@vistaDarDeBaja")->name("vistaDarDeBajaArticulo");
Route::post("articulos/fotos", "ArticulosController@agregarFotos")->name("agregarFotosDeArticulo");
Route::post("articulos/eliminar", "ArticulosController@eliminar")->name("eliminarArticulo");
# Logout
Route::get("logout", function () {
Auth::logout();
# Intentar redireccionar a una protegida, que a su vez redirecciona al login :)
return redirect()->route("articulos");
})->name("logout");
});
Auth::routes(["register" => false]);
Route::get('/home', 'HomeController@index')->name('home');
También se están deshabilitando las rutas de registro, pues solo se pueden registrar usuarios desde la base de datos.
Áreas
Las áreas son los lugares a los que pertenece cada artículo, así, más tarde, se puede filtrar por área. Es un simple CRUD, veamos el modelo:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Area extends Model
{
protected $table = "areas";
public function responsable()
{
return $this->hasOne("App\Responsable", "id");
}
}
El controlador que lo maneja es:
<?php
namespace App\Http\Controllers;
use App\Area;
use App\Http\Requests\GuardarArea;
use App\Http\Requests\GuardarCambiosDeArea;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
class AreasController extends Controller
{
//
public function agregar(GuardarArea $peticion)
{
$area = new Area;
$area->nombre = $peticion->nombre;
$exitoso = $area->save();
$mensaje = "Área agregada correctamente";
$tipo = "success";
if (!$exitoso) {
$mensaje = "Error agregando área. Intente más tarde";
$tipo = "danger";
}
return redirect()->route("formularioArea")
->with("mensaje", $mensaje)
->with("tipo", $tipo);
}
public function mostrar()
{
return Area::orderBy("updated_at", "desc")
->orderBy("created_at", "desc")
->paginate(Config::get("constantes.paginas_en_paginacion"));
}
public function buscar(Request $peticion)
{
$busqueda = urldecode($peticion->busqueda);
return Area::where("nombre", "like", "%$busqueda%")
->paginate(Config::get("constantes.paginas_en_paginacion"));
}
public function editar(Request $peticion)
{
$idArea = $peticion->id;
$area = Area::findOrFail($idArea);
return view("editar_area", [
"area" => $area,
]);
}
public function eliminar($id)
{
$area = Area::find($id);
$area->delete();
}
public function guardarCambios(GuardarCambiosDeArea $peticion)
{
$idArea = $peticion->input("id");
$area = Area::findOrFail($idArea);
$area->nombre = $peticion->input("nombre");
$area->save();
return redirect()->route("areas")->with(["mensaje" => "Área editada correctamente", "tipo" => "success"]);
}
public function eliminarMuchas(Request $peticion)
{
$idsParaEliminar = json_decode($peticion->getContent());
return Area::destroy($idsParaEliminar);
}
}
Aquí notamos algunas cosas interesantes. Por ejemplo, tenemos una búsqueda con LIKE y una paginación:
<?php
public function buscar(Request $peticion)
{
$busqueda = urldecode($peticion->busqueda);
return Area::where("nombre", "like", "%$busqueda%")
->paginate(Config::get("constantes.paginas_en_paginacion"));
}
Por otro lado, tenemos la eliminación por varios Ids, es decir, se recibe un arreglo que tiene varios ID de las áreas, usando JSON y luego se invoca al método destroy
con el arreglo.
<?php
public function eliminarMuchas(Request $peticion)
{
$idsParaEliminar = json_decode($peticion->getContent());
return Area::destroy($idsParaEliminar);
}
Áreas lado del cliente
En el lado del cliente también tenemos algunas buenas cosas. Por ejemplo, la búsqueda y la indicación de carga.
Primero veamos el código:
/*
* Copyright (C) 2019 Luis Cabrera Benito a.k.a. parzibyte
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const RUTA_EDITAR_AREA = URL_BASE + "/areas/editar";
new Vue({
el: "#app",
data: () => ({
buscando: false,
areas: [],
numeroDeElementosMarcados: 0,
cargando: {
eliminandoMuchos: false,
lista: false,
paginacion: false,
},
busqueda: "",
paginacion: {
total: 0,
actual: 0,
ultima: 0,
siguientePagina: "",
paginaAnterior: "",
},
paginas: [],
}),
beforeMount() {
this.refrescarSinQueImporteBusquedaOPagina();
},
computed: {
deberiaDeshabilitarBusqueda() {
return this.areas.length <= 0 && !this.busqueda;
}
},
methods: {
puedeAvanzarPaginacion() {
return this.paginacion.actual < this.paginacion.ultima;
},
puedeRetrocederPaginacion() {
return this.paginacion.actual > 1;
},
avanzarPaginacion() {
if (this.puedeAvanzarPaginacion()) {
this.irALaPagina(this.paginacion.actual + 1);
}
},
retrocederPaginacion() {
if (this.puedeRetrocederPaginacion()) {
this.irALaPagina(this.paginacion.actual - 1);
}
},
limpiarBusqueda() {
this.busqueda = "";
this.refrescarSinQueImporteBusquedaOPagina();
},
buscar: debounce(function () {
if (this.busqueda && !this.buscando) {
this.buscando = true;
this.consultarAreasConUrl(`/areas/buscar/${encodeURIComponent(this.busqueda)}`)
.finally(() => this.buscando = false);
} else {
this.refrescarSinQueImporteBusquedaOPagina();
}
}, 500),
editar(area) {
window.location.href = `${RUTA_EDITAR_AREA}/${area.id}`;
},
eliminarMarcadas() {
if (!confirm("¿Eliminar todos los elementos marcados?")) return;
let arregloParaEliminar = this.areas.filter(area => area.marcada).map(area => area.id);
this.cargando.eliminandoMuchos = true;
HTTP.post("/areas/eliminar", arregloParaEliminar)
.then(resultado => {
})
.finally(() => {
this.desmarcarTodas();
this.refrescarSinQueImporteBusquedaOPagina();
this.cargando.eliminandoMuchos = false;
});
},
onBotonParaMarcarClickeado() {
if (this.areas.some(area => area.marcada)) {
this.desmarcarTodas();
} else {
this.marcarTodas();
}
},
marcarTodas() {
this.numeroDeElementosMarcados = this.areas.length;
this.areas.forEach(area => {
Vue.set(area, "marcada", true);
});
},
desmarcarTodas() {
this.numeroDeElementosMarcados = 0;
this.areas.forEach(area => {
Vue.set(area, "marcada", false);
});
},
invertirEstado(area) {
// Si está marcada, ahora estará desmarcada
if (area.marcada) this.numeroDeElementosMarcados--;
else this.numeroDeElementosMarcados++;
Vue.set(area, "marcada", !area.marcada);
},
eliminar(area) {
if (!confirm(`¿Eliminar área ${area.nombre}?`)) return;
this.desmarcarTodas();
let {id} = area;
Vue.set(area, "eliminando", true);
HTTP.delete(`/area/${id}`)
.then(resultado => {
})
.finally(() => {
this.refrescarSinQueImporteBusquedaOPagina();
})
},
refrescarSinQueImporteBusquedaOPagina() {
let url = this.busqueda ? `/areas/buscar/${encodeURIComponent(this.busqueda)}?page=${this.paginacion.actual}` : "/areas";
this.consultarAreasConUrl(url);
},
consultarAreasConUrl(url) {
this.desmarcarTodas();
this.cargando.lista = true;
return HTTP.get(url)
.then(respuesta => {
this.areas = respuesta.data;
this.establecerPaginacion(respuesta);
})
.finally(() => this.cargando.lista = false);
},
establecerPaginacion(respuesta) {
this.paginacion.siguientePagina = respuesta.next_page_url;
this.paginacion.paginaAnterior = respuesta.prev_page_url;
this.paginacion.actual = respuesta.current_page;
this.paginacion.total = respuesta.total;
this.paginacion.ultima = respuesta.last_page;
this.prepararArregloParaPaginacion();
},
irALaPagina(pagina) {
this.cargando.paginacion = true;
this.consultarAreasConUrl("/areas?page=" + pagina).finally(() => this.cargando.paginacion = false);
},
prepararArregloParaPaginacion() {
// Si no hay más de una página ¿Para qué mostrar algo?
if (this.paginacion.ultima <= 1) return;
this.paginas = [];
// Poner la primera página
this.paginas.push({numero: 1});
// Izquierda de la actual
let posibleIzquierdaDeActual = this.paginacion.actual - 1;
if (posibleIzquierdaDeActual > 1 && posibleIzquierdaDeActual !== this.paginacion.ultima) {
this.paginas.push({numero: posibleIzquierdaDeActual});
// Si entre la izquierda de la actual y la primera hay un espacio grande, poner ...
if (posibleIzquierdaDeActual - 1 > 1) this.paginas.splice(1, 0, {puntosSuspensivos: true})
}
// Poner la actual igualmente si no es la primera o última
if (this.paginacion.actual !== 1 && this.paginacion.actual !== this.paginacion.ultima) {
this.paginas.push({numero: this.paginacion.actual});
}
// Derecha de la actual
let posibleDerechaDeActual = this.paginacion.actual + 1;
if (posibleDerechaDeActual !== 1 && posibleDerechaDeActual < this.paginacion.ultima) {
this.paginas.push({numero: posibleDerechaDeActual});
// Si entre la derecha de la actual y la última hay un espacio grande, poner ...
if (posibleDerechaDeActual + 1 < this.paginacion.ultima) this.paginas.push({puntosSuspensivos: true})
}
// Última
this.paginas.push({numero: this.paginacion.ultima});
}
}
});
Lo que hacemos es manejar toda la interfaz. Lo que me gusta es mostrar el estado de cargando:
<div v-show="cargando.lista" class="notification is-info has-text-centered">
<h3 class="is-size-3">Cargando</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-spinner fa-spin"></i>
</h1>
</div>
<p class="is-size-5">
Por favor, espera un momento
</p>
</div>
De igual modo se muestra lo siguiente cuando no hay resultados de la búsqueda:
Lo que se logra con el siguiente código:
<transition name="fade">
<div v-show="areas.length <= 0 && busqueda && !cargando.lista"
class="notification is-warning has-text-centered">
<h3 class="is-size-3">No hay resultados</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-search"></i>
</h1>
</div>
<p class="is-size-5">
No hay resultados que coincidan con tu búsqueda
</p>
</div>
</transition>
Y finalmente si no hay áreas se muestra lo siguiente:
<transition name="fade">
<div v-show="areas.length <= 0 && !busqueda && !cargando.lista"
class="notification is-info has-text-centered">
<h3 class="is-size-3">No hay áreas</h3>
<div>
<h1 class="icono-gigante">
<i class="fas fa-box-open"></i>
</h1>
</div>
<p class="is-size-5">
Parece que no has agregado ninguna área. Registra una haciendo click en el botón
<strong>Agregar</strong>
</p>
</div>
</transition>
Responsables
Se supone que cada responsable es el encargado de un área. Y las áreas tienen artículos. Del mismo modo es un CRUD agregando la búsqueda:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Responsable extends Model
{
public function area()
{
return $this->belongsTo("App\Area", "areas_id");
}
}
Fíjate en que tiene una relación (a nivel de base de datos) con el área. Del mismo modo es un simple controlador de Laravel:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\GuardarCambiosDeResponsableRequest;
use App\Http\Requests\GuardarResponsableRequest;
use Illuminate\Support\Facades\Config;
use App\Responsable;
use Illuminate\Http\Request;
class ResponsablesController extends Controller
{
//
public function agregar(GuardarResponsableRequest $peticion)
{
$datosDecodificados = json_decode($peticion->getContent());
$responsable = new Responsable;
$responsable->nombre = $datosDecodificados->nombre;
$responsable->direccion = $datosDecodificados->direccion;
$responsable->areas_id = $datosDecodificados->areas_id;
return response()->json($responsable->save());
}
public function mostrar()
{
return Responsable::orderBy("updated_at", "desc")
->orderBy("created_at", "desc")
->with("area")
->paginate(Config::get("constantes.paginas_en_paginacion"));
}
public function guardarCambios(GuardarCambiosDeResponsableRequest $peticion)
{
$datosDecodificados = json_decode($peticion->getContent());
$idResponsable = $datosDecodificados->id;
$responsable = Responsable::findOrFail($idResponsable);
$responsable->nombre = $datosDecodificados->nombre;
$responsable->direccion = $datosDecodificados->direccion;
$responsable->areas_id = $datosDecodificados->areas_id;
return response()->json($responsable->save());
}
public function buscar(Request $peticion)
{
$busqueda = urldecode($peticion->busqueda);
return Responsable::where("nombre", "like", "%$busqueda%")
->with("area")
->paginate(Config::get("constantes.paginas_en_paginacion"));
}
public function porId(Request $peticion)
{
$idResponsable = $peticion->id;
$responsable = Responsable::where("id", "=", $idResponsable)->with("Area")->first();
return response()->json($responsable);
}
public function eliminar($id)
{
$responsable = Responsable::find($id);
$responsable->delete();
}
public function eliminarMuchos(Request $peticion)
{
$idsParaEliminar = json_decode($peticion->getContent());
return Responsable::destroy($idsParaEliminar);
}
}
Artículos
Ahora veamos la parte de los artículos del inventario. Esta es la parte central del programa. Comenzamos viendo cómo se ve la administración de los mismos:
Los artículos se registran, editan y dan de baja pero también tienen fotos, que puede ser una foto o varias.
Registrar artículo
Para registrar un artículo se necesita:
- Fecha de adquisición
- Marca
- Modelo
- Serie
- Código
- Folio de comprobante
- Descripción
- Estado
- Costo de adquisición
- Área
- Observaciones
Tenemos el modelo que va a interactuar con la base de datos. Tiene una relación con el modelo Area
y el modelo FotoDeArticulo
:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Articulo extends Model
{
protected $table = "articulos";
public function area()
{
return $this->belongsTo("App\Area", "areas_id");
}
public function fotos()
{
return $this->hasMany("App\FotoDeArticulo", "articulos_id");
}
}
Dentro del formulario tenemos una especie de autocompletado de las áreas que es una implementación manual.
Autocompletado con Vue para las áreas
Dentro del formulario para editar o agregar artículos (así como responsables) está un autocompletado que se ve así:
<nav class="panel">
<div class="panel-block">
<p class="control">
<label class="label">Área</label>
<input @focus="mostrar.areas = true" v-model="busqueda"
@keyup="buscarArea()" class="input" type="text"
placeholder="Buscar área">
</p>
</div>
@verbatim
<a v-show="mostrar.areas && busqueda" @click="seleccionarArea(area)"
v-for="area in areas"
class="panel-block" :class="{'is-active': area.id === areaSeleccionada.id}">
<span class="panel-icon">
<i class="fas fa-building" aria-hidden="true"></i>
</span>
{{area.nombre}}
</a>
<div v-show="!mostrar.areas && areaSeleccionada.id" class="notification is-info">
<h4 class="is-size-4">Área: {{areaSeleccionada.nombre}}</h4>
</div>
</nav>
Su funcionamiento se hace con Vue:
/*
* Copyright (C) 2019 Luis Cabrera Benito a.k.a. parzibyte
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
new Vue({
el: "#app",
data: () => ({
areas: [],
busqueda: "",
areaSeleccionada: {},
mostrar: {
areas: false,
aviso: false,
},
articulo: {
fechaAdquisicion: "",
codigo: "",
numeroFolioComprobante: "",
descripcion: "",
marca: "",
modelo: "",
serie: "",
estado: "regular",
observaciones: "",
costoAdquisicion: "",
},
errores: [],
cargando: false,
aviso: {},
}),
methods: {
guardar() {
this.mostrar.aviso = false;
if (!this.validar()) return;
this.cargando = true;
HTTP
.post("/articulo", {
fechaAdquisicion: this.articulo.fechaAdquisicion,
codigo: this.articulo.codigo,
numeroFolioComprobante: this.articulo.numeroFolioComprobante,
descripcion: this.articulo.descripcion,
marca: this.articulo.marca,
modelo: this.articulo.modelo,
serie: this.articulo.serie,
estado: this.articulo.estado,
observaciones: this.articulo.observaciones,
costoAdquisicion: this.articulo.costoAdquisicion,
areas_id: this.areaSeleccionada.id
})
.then(resultado => {
resultado && this.resetear();
this.mostrar.aviso = true;
this.aviso.mensaje = resultado ? "Artículo agregado con éxito" : "Error agregando artículo. Intenta de nuevo";
this.aviso.tipo = resultado ? "is-success" : "is-danger";
})
.finally(() => this.cargando = false);
},
validar() {
this.errores = [];
if (!this.articulo.fechaAdquisicion.trim())
this.errores.push("Selecciona la fecha de adquisición");
if (!this.articulo.codigo.trim())
this.errores.push("Escribe el código del artículo");
if (this.articulo.codigo.length > 255)
this.errores.push("El código no debe contener más de 255 caracteres");
if (!this.articulo.descripcion.trim())
this.errores.push("Escribe la descripción del artículo");
if (this.articulo.descripcion.length > 255)
this.errores.push("La descripción no debe contener más de 255 caracteres");
if (!this.articulo.estado)
this.errores.push("Selecciona el estado del artículo");
if (!parseFloat(this.articulo.costoAdquisicion))
this.errores.push("Escribe el costo de adquisición del artículo");
if (parseFloat(this.articulo.costoAdquisicion) <= 0)
this.errores.push("El costo de adquisición debe ser mayor a 0");
if (parseFloat(this.articulo.costoAdquisicion) > 99999999.99)
this.errores.push("El costo de adquisición debe ser menor que 100000000");
if (!this.areaSeleccionada.id)
this.errores.push("Selecciona un área");
return this.errores.length <= 0;
},
seleccionarArea(area) {
this.areaSeleccionada = area;
this.mostrar.areas = false;
},
resetear() {
this.areas = [];
this.areaSeleccionada = {};
this.articulo = {
fechaAdquisicion: "",
codigo: "",
numeroFolioComprobante: "",
descripcion: "",
marca: "",
modelo: "",
serie: "",
estado: "regular",
observaciones: "",
costoAdquisicion: "",
};
this.errores = [];
this.cargando = false;
this.busqueda = "";
},
buscarArea: debounce(function () {
if (!this.busqueda) return;
HTTP.get("/areas/buscar/" + encodeURIComponent(this.busqueda))
.then(areas => this.areas = areas.data)
}, 500)
}
});
Fotos de artículos
Dentro de este sistema open source se pueden agregar fotos de artículos haciendo click en Administrar fotos. Es una página que muestra las fotos que ya existen además de un formulario para subir:
PD: Xiaomi es calidad precio
Es buen momento para mostrar cómo es el modelo de la Foto de artículo:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class FotoDeArticulo extends Model
{
//
protected $table = "fotos_articulos";
}
Y su respectiva migración:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CrearFotosDeArticulos extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('fotos_articulos', function (Blueprint $table) {
$table->increments('id');
$table->timestamps();
$table->string("ruta", 255);
$table->unsignedInteger("articulos_id");
$table->foreign("articulos_id")
->references("id")
->on("articulos")
->onDelete("cascade")
->onUpdate("cascade");
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('fotos_articulos');
}
}
Lo único que guardo es la ruta de la foto, es decir, en dónde fue guardada, pero la foto real se guarda en el disco duro.
Para gestionar las fotos en el almacenamiento se usa el mismo controlador de los artículos, resaltando los siguientes métodos que a su vez usan la fachada de Storage
provista por Laravel.
<?php
private $nombreCarpetaFotos = "fotos";
private $nombreCarpetaAdjuntos = "adjuntos";
private $TIPO_ADJUNTOS_BAJA = "BAJA";
private $nombreFoto404 = "error_404.jpeg";
public function foto(Request $peticion)
{
$nombreFoto = $peticion->nombre;
$rutaDeImagen = storage_path("app/" . $this->nombreCarpetaFotos . "/" . $this->obtenerRutaSegura($nombreFoto));
return response()->file($rutaDeImagen);
}
public function descargar(Request $peticion)
{
$nombreFoto = $peticion->nombre;
$rutaDeImagen = storage_path("app/" . $this->nombreCarpetaFotos . "/" . $this->obtenerRutaSegura($nombreFoto));
return response()->download($rutaDeImagen);
}
public function eliminarFoto(Request $peticion)
{
$datosDecodificados = json_decode($peticion->getContent());
$nombreFoto = $this->obtenerRutaSegura($datosDecodificados->nombre);
if ($nombreFoto === $this->nombreFoto404) return [];
Storage::disk("local")->delete("fotos/$nombreFoto");
return response()->json([
"archivo" => Storage::disk("local")->delete("fotos/$nombreFoto"), // Eliminar el archivo físico
"bd" => FotoDeArticulo::where("ruta", "=", $nombreFoto)->delete() // y quitar el registro de la base de datos
]);
}
private function obtenerRutaSegura($rutaInsegura)
{
# Verifica si la ruta que se quiere leer realmente pertenece a un artículo, en caso de que sí, regresa la
#misma ruta pero tomada de la base de datos
$posibleRegistro = FotoDeArticulo::where("ruta", "=", $rutaInsegura)->first();
if ($posibleRegistro === null) return $this->nombreFoto404;
return $posibleRegistro->ruta;
}
Esta parte se me hace interesante pues nos permite:
- Eliminar una foto
- Agregar una foto
- Obtener una ruta segura para servir la foto
- Descargar la foto
Finalmente quiero que se vea el código para el formulario de agregar las fotos:
@extends("maestra")
@section("titulo", "Fotos de artículo")
@section("contenido")
<div class="container" id="app">
<div class="columns">
<div class="column">
<h1 class="is-size-1">Fotos de artículo ({{count($articulo->fotos)}})</h1>
</div>
</div>
<div class="columns notification">
<div class="column">
<strong>{{$articulo->descripcion}} |
{{$articulo->marca}} |
{{$articulo->modelo}} |
{{$articulo->serie}}</strong>
<br>
<strong>Área: </strong> {{$articulo->area->nombre}}<br>
<strong>Estado: </strong> {{$articulo->estado}}<br>
<strong>Observaciones: </strong> {{$articulo->observaciones}}
</div>
<div class="column">
<div class="field is-horizontal">
<form enctype="multipart/form-data" method="post" action="{{route("agregarFotosDeArticulo")}}">
<div class="file is-info has-name is-boxed">
<label class="file-label">
@csrf
<input type="hidden" name="id" value="{{$articulo->id}}">
<input accept="image/jpeg,image/png" ref="fotos" @change="onFotosCambiadas" multiple
class="file-input" type="file" name="fotos[]">
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-images"></i>
</span>
<span class="file-label">
Seleccionar fotos
</span>
</span>
@verbatim
<span class="file-name" v-for="foto in fotos">
{{foto.name}}
</span>
@endverbatim
</label>
</div>
<div class="field"><br>
<button :disabled="fotos.length <= 0" class="button is-success" type="submit">Subir</button>
</div>
</form>
</div>
@include("errores")
@include("notificacion")
</div>
</div>
@php
$mostrarPorFila = 3
@endphp
{{-- https://parzibyte.me/blog/2019/03/02/blade-laravel-ciclos-condicionales-token-csrf-componentes/ --}}
@forelse($articulo->fotos as $foto)
{{--
Nota: los comentarios suponen que $mostrarPorFila es 3. Si no, igual funciona pero no entenderás los comentarios
Abrir div si es elemento es 1, 4, 7, etcétera (comenzando a contar en 1)--}}
@if($loop->iteration % $mostrarPorFila === 1)
<div class="columns">
@endif
<div class="column">
<div class="card">
<div class="card-image">
<figure class="image is-4by3">
<img src="{{route("fotoDeArticulo", ["nombre" => $foto->ruta])}}"
alt="Placeholder image">
</figure>
</div>
<footer class="card-footer">
<a href="{{route("fotoDeArticulo", ["nombre" => $foto->ruta])}}" target="_blank"
class="card-footer-item">Ampliar</a>
<a @click="eliminar('{{$foto->ruta}}')" class="card-footer-item">Eliminar</a>
<a href="{{route("descargarFotoDeArticulo", ["nombre" => $foto->ruta])}}"
class="card-footer-item">Descargar</a>
</footer>
</div>
</div>
{{--
Cerrar el div. Ya sea porque llegamos al final y corrimos con la suerte de que el
arreglo tuviera una longitud múltipla de 3 ($loop->iteration % 3 === 0 && $loop->iteration !== 0)
O cerrarlo porque es el último elemento y la longitud no era múltiplo de 3
($loop->last && $loop->count % 3 !== 0)
--}}
@if(($loop->iteration % $mostrarPorFila === 0 && $loop->iteration !== 0) || ($loop->last && $loop->count % $mostrarPorFila !== 0))
</div>
@endif
@empty
<div class="columns">
<div class="column">
<h1 class="is-size-1">No hay fotos</h1>
</div>
</div>
@endforelse
</div>
<script src="{{url("/js/articulos/fotos.js?q=") . time()}}"></script>
@endsection
Ahí he discutido un poco por cómo mostrar las filas de las fotos, pero al final ha funcionado.
Dar de baja un artículo
Esta parte todavía no está terminada, pero se permite dar de baja un artículo; solo que por seguridad se debe adjuntar un archivo o comprobante para después mostrar por qué se ha dado de baja:
Login
Finalmente veamos que el sistema cuenta con autenticación para ser administrado. El login se ve así:
El código en realidad no importa mucho; simplemente se envían las credenciales a la ruta que Laravel ya tiene por defecto para hacer la acción:
@extends("maestra")
@section("titulo", "Iniciar sesión")
@section("contenido")
<style>
@import "https://fonts.googleapis.com/css?family=Open+Sans:300,400,700";
html, body {
font-family: 'Open Sans', serif;
font-size: 14px;
font-weight: 300;
}
.hero.is-success {
background: #F2F6FA;
}
.hero .nav, .hero.is-success .nav {
-webkit-box-shadow: none;
box-shadow: none;
}
.box {
margin-top: 5rem;
}
.avatar {
margin-top: -70px;
padding-bottom: 20px;
}
.avatar img {
padding: 5px;
background: #fff;
border-radius: 50%;
-webkit-box-shadow: 0 2px 3px rgba(10, 10, 10, .1), 0 0 0 1px rgba(10, 10, 10, .1);
box-shadow: 0 2px 3px rgba(10, 10, 10, .1), 0 0 0 1px rgba(10, 10, 10, .1);
}
input {
font-weight: 300;
}
p {
font-weight: 700;
}
p.subtitle {
padding-top: 1rem;
}
</style>
<section class="hero is-success is-fullheight" id="app">
<div class="hero-body">
<div class="container has-text-centered">
<div class="column is-4 is-offset-4">
<h3 class="title has-text-grey">Bienvenido </h3>
<p class="subtitle has-text-grey">Inicia sesión</p>
@include("errores")
@include("notificacion")
<div class="box">
<figure class="avatar" style="padding: 0">
<img style="width: 130px;" src="{{url("/img/logo-cuadrado.jpg") }}">
</figure>
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="field">
<div class="control">
<input required class="input is-large" type="text"
placeholder="Usuario" name="nombre"
autofocus="">
</div>
</div>
<div class="field">
<div class="control">
<input required class="input is-large" type="password" name="password"
placeholder="Contraseña">
</div>
</div>
<div class="field">
<label class="checkbox">
<input name="remember" type="checkbox">
Recordarme
</label>
</div>
<button type="submit" class="button is-block is-info is-large is-fullwidth">Ingresar
</button>
</form>
</div>
<p class="has-text-grey">
Si no tienes una cuenta, pide a los administradores que te registren
</p>
</div>
</div>
</div>
</section>
@endsection
¿Por qué no está terminado?
Ahora sí a la historia. Pasa que hace algún tiempo tenía que hacer el servicio de la universidad, es decir, una cosa que tienes que hacer para ayudar a una institución pública o algo así.
Al final por algunos problemas ya no lo hice, pero el sistema ya estaba iniciado, así que en lugar de dejarlo en el olvido, lo vengo a publicar aquí. A mi parecer tiene varias cosas interesantes como el diseño de la interfaz, el login, el estado de búsqueda y la administración de fotos.
Código fuente
Puedes ver el código fuente en mi github. Ahí encontrarás, en el README, las instrucciones para instalarlo. Es como cualquier otro proyecto de Laravel.
En caso de que te guste, te invito a aprender más sobre Laravel en mi blog.
Excelente aporte para conocer un poco mas de Laravel.
Gracias por tu tremendo aporte Luis, y por tus sugerencias a utilizar este framework.