feature-comercial-module-ts #10
114
src/components/ui/Input.vue
Normal file
114
src/components/ui/Input.vue
Normal 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>
|
||||||
@ -100,6 +100,11 @@ onMounted(() => {
|
|||||||
name="Clasificaciones Comerciales"
|
name="Clasificaciones Comerciales"
|
||||||
to="admin.comercial-classifications.index"
|
to="admin.comercial-classifications.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
icon="inventory"
|
||||||
|
name="Productos"
|
||||||
|
to="admin.products.index"
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section name="Capacitaciones">
|
<Section name="Capacitaciones">
|
||||||
|
|||||||
229
src/pages/Products/Index.vue
Normal file
229
src/pages/Products/Index.vue
Normal 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>
|
||||||
23
src/pages/Products/Module.js
Normal file
23
src/pages/Products/Module.js
Normal 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
300
src/pages/Products/a.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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',
|
path: 'roles',
|
||||||
name: 'admin.roles',
|
name: 'admin.roles',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user