Software y sistemas

Sistema de administración de inventario en Laravel

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>&nbsp;Áreas
                </a>
                <a class="navbar-item" href="{{ route("responsables") }}">
                    <span class="icon has-text-success">
                        <i class="fa fa-users"></i>
                    </span>&nbsp;Responsables
                </a>
                <a class="navbar-item" href="{{ route("articulos") }}">
                    <span class="icon has-text-info">
                        <i class="fa fa-box"></i>
                    </span>&nbsp;Inventario
                </a>
                <a class="navbar-item" href="#">
                    <span class="icon has-text-info">
                        <i class="fa fa-chart-line"></i>
                    </span>&nbsp;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>&nbsp;({{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>
                            &nbsp;
                        </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>&hellip;</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

Áreas de inventario

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:

Status cargando con Vue
<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:

Búsqueda sin resultados

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

Responsables de artículos

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:

Artículos con fotos – Software en Laravel

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

Agregar artículo en sistema de inventarios con Laravel

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

Autocompletado con Vue y Laravel

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:

Fotos del artículo

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}}&nbsp;|
                    {{$articulo->marca}}&nbsp;|
                    {{$articulo->modelo}}&nbsp;|
                    {{$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:

Dar de baja un artículo

Login

Finalmente veamos que el sistema cuenta con autenticación para ser administrado. El login se ve así:

Login en sistema de administración de inventarios

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.

Aquí el repositorio.

En caso de que te guste, te invito a aprender más sobre Laravel en mi blog.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.
parzibyte

Programador freelancer listo para trabajar contigo. Aplicaciones web, móviles y de escritorio. PHP, Java, Go, Python, JavaScript, Kotlin y más :) https://parzibyte.me/blog/software-creado-por-parzibyte/

Ver comentarios

  • Excelente aporte para conocer un poco mas de Laravel.
    Gracias por tu tremendo aporte Luis, y por tus sugerencias a utilizar este framework.

Entradas recientes

Servidor HTTP en Android con Flutter

El día de hoy te mostraré cómo crear un servidor HTTP (servidor web) en Android…

4 días hace

Imprimir automáticamente todos los PDF de una carpeta

En este post te voy a enseñar a designar una carpeta para imprimir todos los…

5 días hace

Guía para imprimir en plugin versión 1 desde Android

En este artículo te voy a enseñar la guía para imprimir en una impresora térmica…

1 semana hace

Añadir tasa de cambio en sistema de información

Hoy te voy a mostrar un ejemplo de programación para agregar un módulo de tasa…

2 semanas hace

Comprobar validez de licencia de plugin ESC POS

Los usuarios del plugin para impresoras térmicas pueden contratar licencias, y en ocasiones me han…

2 semanas hace

Imprimir euro € en impresora térmica

Hoy voy a enseñarte cómo imprimir el € en una impresora térmica. Vamos a ver…

4 semanas hace

Esta web usa cookies.