ADD: Administrador de productos(WIP)

This commit is contained in:
Edgar Méndez Mendoza 2025-10-04 09:23:04 -06:00
parent 2bd5d00827
commit 83f0abff13
6 changed files with 705 additions and 0 deletions

114
src/components/ui/Input.vue Normal file
View File

@ -0,0 +1,114 @@
<script setup>
import { computed } from 'vue';
/** Props */
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
class: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
required: {
type: Boolean,
default: false
},
id: {
type: String,
default: undefined
},
name: {
type: String,
default: undefined
}
});
/** Emits */
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'keydown', 'keyup']);
/** Función para concatenar clases (equivalente a cn() de React) */
const cn = (...classes) => {
return classes.filter(Boolean).join(' ');
};
/** Clases computadas */
const inputClasses = computed(() => {
return cn(
"flex h-10 w-full rounded-md border border-input px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
props.class
);
});
/** Manejadores de eventos */
const handleInput = (event) => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
const handleChange = (event) => {
emit('change', event);
};
const handleFocus = (event) => {
emit('focus', event);
};
const handleBlur = (event) => {
emit('blur', event);
};
const handleKeydown = (event) => {
emit('keydown', event);
};
const handleKeyup = (event) => {
emit('keyup', event);
};
</script>
<template>
<input
:id="id"
:name="name"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:class="inputClasses"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@keyup="handleKeyup"
/>
</template>
<style scoped>
/*
Nota: Las clases CSS están definidas en Tailwind CSS.
Si necesitas definir estilos personalizados para las clases como
'border-input', 'bg-background', 'ring-ring', etc.,
puedes agregarlas aquí o en tu archivo de configuración de Tailwind.
*/
</style>

View File

@ -100,6 +100,11 @@ onMounted(() => {
name="Clasificaciones Comerciales"
to="admin.comercial-classifications.index"
/>
<Link
icon="inventory"
name="Productos"
to="admin.products.index"
/>
</Section>
<Section name="Capacitaciones">

View File

@ -0,0 +1,229 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import Button from '@Holos/Button/Button.vue';
import Card from '@Holos/Card/Card.vue';
import CardContent from '@Holos/Card/CardContent.vue';
import CardHeader from '@Holos/Card/CardHeader.vue';
import CardTitle from '@Holos/Card/CardTitle.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Table from '@Holos/Table.vue';
import IconButton from '@Holos/Button/Icon.vue';
import { can, apiTo, viewTo, transl } from './Module'
import { useSearcher } from '@Services/Api';
import Input from '@Components/ui/Input.vue';
const models = ref([]);
const router = useRouter();
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (r) => {
console.log('Datos recibidos:', r);
// Según la estructura que muestras, los productos están en r.data.products.data
models.value = r.data?.products?.data || r.products || [];
},
onError: (error) => {
console.error('Error cargando productos:', error);
models.value = [];
}
});
/** Función para formatear atributos */
const formatAttributes = (attributes) => {
if (!attributes) return '-';
const formatted = Object.entries(attributes).map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}`;
}
return `${key}`;
}).join(' | ');
return formatted.length > 50 ? formatted.substring(0, 50) + '...' : formatted;
};
/** Función para mostrar clasificaciones */
const formatClassifications = (classifications) => {
if (!classifications || classifications.length === 0) return '-';
return classifications.map(c => c.name).join(', ');
};
onMounted(() => {
searcher.search();
})
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Productos</h1>
<p className="text-muted-foreground">
Gestión del catálogo de productos
</p>
</div>
<Button color="info" @click="$router.push({ name: 'products.create' })">
Nuevo Producto
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent class="p-4">
<div class="flex items-center space-x-2">
<GoogleIcon class="w-8 h-8 text-primary" name="inventory_2" />
<div>
<p class="text-2xl font-bold text-metric-value">150</p>
<p class="text-sm text-muted-foreground">Total Productos</p>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Catálogo de Productos</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center space-x-4 mb-6">
<div class="relative flex-1 max-w-sm">
<GoogleIcon class="absolute left-3 top-2 h-4 w-4 text-muted-foreground" name="search" />
<Input
placeholder="Buscar productos..."
class="pl-10"
@input="(e) => searcher.search({ search: e.target.value })"
/>
</div>
<Button variant="outline" class="flex items-center">
<GoogleIcon class="w-4 h-4 mr-2" name="filter_list" />
Filtros
</Button>
</div>
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th>{{ $t('code') }}</th>
<th>SKU</th>
<th>{{ $t('name') }}</th>
<th>{{ $t('description') }}</th>
<th>Atributos</th>
<th>Clasificaciones</th>
<th class="w-20 text-center">{{ $t('status') }}</th>
<th class="w-32 text-center">{{ $t('actions') }}</th>
</template>
<template #body="{ items }">
<tr v-for="product in items" :key="product.id" class="table-row">
<td class="table-cell">
<div class="flex items-center">
<GoogleIcon name="inventory" class="text-blue-500 mr-2" />
<code class="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{{ product.code }}
</code>
</div>
</td>
<td class="table-cell">
<span class="font-mono text-sm text-gray-600 dark:text-gray-400">
{{ product.sku }}
</span>
</td>
<td class="table-cell">
<span class="font-semibold">{{ product.name }}</span>
</td>
<td class="table-cell">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ product.description || '-' }}
</span>
</td>
<td class="table-cell">
<span class="text-xs text-gray-500 dark:text-gray-400" :title="JSON.stringify(product.attributes)">
{{ formatAttributes(product.attributes) }}
</span>
</td>
<td class="table-cell">
<div class="flex flex-wrap gap-1">
<span
v-for="classification in product.classifications"
:key="classification.id"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100"
>
{{ classification.name }}
</span>
<span v-if="!product.classifications || product.classifications.length === 0" class="text-gray-400 text-xs">
Sin clasificar
</span>
</div>
</td>
<td class="table-cell text-center">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:class="product.is_active
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'"
>
{{ product.is_active ? $t('active') : $t('inactive') }}
</span>
</td>
<td class="table-cell">
<div class="table-actions">
<IconButton
icon="visibility"
:title="$t('crud.show')"
outline
/>
<IconButton
icon="edit"
:title="$t('crud.edit')"
outline
@click="$router.push({ name: 'products.edit', params: { id: product.id } })"
/>
<IconButton
icon="delete"
:title="$t('crud.destroy')"
outline
/>
</div>
</td>
</tr>
</template>
<template #empty>
<td class="table-cell" colspan="8">
<div class="flex flex-col items-center text-center py-8">
<GoogleIcon name="inventory" class="text-4xl text-gray-400 mb-2" />
<p class="font-semibold text-gray-600 dark:text-gray-400">
{{ $t('registers.empty') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
No se encontraron productos
</p>
</div>
</td>
</template>
</Table>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,23 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`products.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({
name: `admin.products.${name}`, params, query
})
// Obtener traducción del componente
const transl = (str) => lang(`admin.products.${str}`)
// Control de permisos
const can = (permission) => hasPermission(`admin.products.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

300
src/pages/Products/a.jsx Normal file
View File

@ -0,0 +1,300 @@
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Plus,
Search,
Filter,
Edit,
Trash2,
Package,
MoreHorizontal
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Mock data
const products = [
{
id: 1,
code: "PROD-001",
name: "Laptop Dell Inspiron 15",
description: "Laptop empresarial de alto rendimiento",
category: "Electrónicos > Computadoras",
brand: "Dell",
price: 15999.00,
stock: 45,
minStock: 10,
status: "active",
lastUpdated: "2024-01-15"
},
{
id: 2,
code: "PROD-002",
name: "Mouse Inalámbrico Logitech",
description: "Mouse óptico inalámbrico con receptor USB",
category: "Electrónicos > Accesorios",
brand: "Logitech",
price: 599.00,
stock: 120,
minStock: 25,
status: "active",
lastUpdated: "2024-01-14"
},
{
id: 3,
code: "PROD-003",
name: "Monitor LG 24 pulgadas",
description: "Monitor LED Full HD con conexión HDMI",
category: "Electrónicos > Monitores",
brand: "LG",
price: 3299.00,
stock: 8,
minStock: 15,
status: "low-stock",
lastUpdated: "2024-01-13"
},
{
id: 4,
code: "PROD-004",
name: "Teclado Mecánico RGB",
description: "Teclado mecánico para gaming con iluminación RGB",
category: "Electrónicos > Accesorios",
brand: "Corsair",
price: 2199.00,
stock: 0,
minStock: 5,
status: "out-of-stock",
lastUpdated: "2024-01-12"
},
{
id: 5,
code: "PROD-005",
name: "Webcam HD Logitech",
description: "Cámara web HD para videoconferencias",
category: "Electrónicos > Accesorios",
brand: "Logitech",
price: 899.00,
stock: 32,
minStock: 10,
status: "active",
lastUpdated: "2024-01-11"
}
]
const getStatusBadge = (status: string, stock: number, minStock: number) => {
if (stock === 0) {
return <Badge variant="destructive">Sin Stock</Badge>
}
if (stock <= minStock) {
return <Badge className="bg-warning text-warning-foreground">Stock Bajo</Badge>
}
return <Badge className="bg-success text-success-foreground">Activo</Badge>
}
export default function Products() {
const [searchTerm, setSearchTerm] = useState("")
const [filteredProducts, setFilteredProducts] = useState(products)
const handleSearch = (term: string) => {
setSearchTerm(term)
const filtered = products.filter(product =>
product.name.toLowerCase().includes(term.toLowerCase()) ||
product.code.toLowerCase().includes(term.toLowerCase()) ||
product.brand.toLowerCase().includes(term.toLowerCase())
)
setFilteredProducts(filtered)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Productos</h1>
<p className="text-muted-foreground">
Gestión del catálogo de productos
</p>
</div>
<Button className="bg-primary hover:bg-primary-hover">
<Plus className="w-4 h-4 mr-2" />
Nuevo Producto
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<Package className="w-8 h-8 text-primary" />
<div>
<p className="text-2xl font-bold text-metric-value">{products.length}</p>
<p className="text-sm text-muted-foreground">Total Productos</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 rounded-full bg-success flex items-center justify-center">
<span className="text-success-foreground font-bold text-sm"></span>
</div>
<div>
<p className="text-2xl font-bold text-success">
{products.filter(p => p.status === "active").length}
</p>
<p className="text-sm text-muted-foreground">Activos</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 rounded-full bg-warning flex items-center justify-center">
<span className="text-warning-foreground font-bold text-sm">!</span>
</div>
<div>
<p className="text-2xl font-bold text-warning">
{products.filter(p => p.status === "low-stock").length}
</p>
<p className="text-sm text-muted-foreground">Stock Bajo</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 rounded-full bg-destructive flex items-center justify-center">
<span className="text-destructive-foreground font-bold text-sm">×</span>
</div>
<div>
<p className="text-2xl font-bold text-destructive">
{products.filter(p => p.status === "out-of-stock").length}
</p>
<p className="text-sm text-muted-foreground">Sin Stock</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters and Search */}
<Card>
<CardHeader>
<CardTitle>Catálogo de Productos</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4 mb-6">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar productos..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline">
<Filter className="w-4 h-4 mr-2" />
Filtros
</Button>
</div>
{/* Products Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Código</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Categoría</TableHead>
<TableHead>Marca</TableHead>
<TableHead>Precio</TableHead>
<TableHead>Stock</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProducts.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-mono text-sm">
{product.code}
</TableCell>
<TableCell>
<div>
<p className="font-medium">{product.name}</p>
<p className="text-sm text-muted-foreground">
{product.description}
</p>
</div>
</TableCell>
<TableCell className="text-sm">
{product.category}
</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell className="font-mono">
${product.price.toLocaleString('es-MX')}
</TableCell>
<TableCell>
<div className="text-right">
<p className="font-medium">{product.stock}</p>
<p className="text-xs text-muted-foreground">
Min: {product.minStock}
</p>
</div>
</TableCell>
<TableCell>
{getStatusBadge(product.status, product.stock, product.minStock)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Edit className="w-4 h-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem>
<Package className="w-4 h-4 mr-2" />
Ver Stock
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -528,6 +528,40 @@ const router = createRouter({
}
]
},
{
path: 'products',
name: 'admin.products',
meta: {
title: 'Productos',
icon: 'inventory',
},
redirect: '/admin/products',
children: [
{
path: '',
name: 'admin.products.index',
component: () => import('@Pages/Products/Index.vue'),
},
/* {
path: 'create',
name: 'admin.products.create',
component: () => import('@Pages/Products/Create.vue'),
meta: {
title: 'Crear',
icon: 'add',
},
},
{
path: ':id/edit',
name: 'admin.products.edit',
component: () => import('@Pages/Products/Edit.vue'),
meta: {
title: 'Editar',
icon: 'edit',
},
} */
]
},
{
path: 'roles',
name: 'admin.roles',