feat(purchases): add purchase details, form, and listing components

- Implemented PurchaseDetails.vue for displaying detailed purchase information.
- Created PurchaseForm.vue for submitting new purchase requests with supplier and item management.
- Developed Purchases.vue for listing all purchase orders with actions for approval, rejection, and conversion.
- Added purchaseServices.ts for API interactions related to purchases.
- Defined types for purchase forms and purchases in respective TypeScript files.
- Integrated PrimeVue components for UI consistency and functionality.
This commit is contained in:
Edgar Méndez Mendoza 2026-02-10 02:02:58 -06:00
parent 4a624f490c
commit 71454dda61
16 changed files with 1240 additions and 3 deletions

1
components.d.ts vendored
View File

@ -35,6 +35,7 @@ declare module 'vue' {
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
Menu: typeof import('primevue/menu')['default']
Message: typeof import('primevue/message')['default']
Paginator: typeof import('primevue/paginator')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />

View File

@ -27,6 +27,15 @@ const menuItems = ref<MenuItem[]>([
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' },
]
},
{
label: 'Compras',
icon: 'pi pi-shopping-bag',
items: [
{ label: 'Solicitudes de Compra', icon: 'pi pi-shopping-cart', to: '/purchases/requests' },
{ label: 'Órdenes de Compra', icon: 'pi pi-file', to: '/purchases/orders' },
{ label: 'Recepciones de Compra', icon: 'pi pi-inbox', to: '/purchases/receipts' }
]
},
{
label: 'Almacén',
icon: 'pi pi-box',

View File

@ -212,7 +212,7 @@ const typeLabel = (type: string) => {
<Card>
<template #content>
<div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[240px]">
<div class="flex-1 min-w-60">
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Buscar
Nombre</label>
<InputText v-model="searchName" placeholder="Ej. TechLogistics S.A." class="w-full" />

View File

@ -0,0 +1,40 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { supplierServices } from '../services/supplierServices';
import type { Supplier } from '../types/suppliers';
export const useSupplierStore = defineStore('supplier', () => {
const suppliers = ref<Supplier[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchAllSuppliers() {
loading.value = true;
error.value = null;
try {
const response = await supplierServices.getSuppliers(false);
// Si la respuesta es paginada, usar response.data; si es lista, usar response.suppliers o response directamente
if (Array.isArray(response)) {
suppliers.value = response;
} else if ('suppliers' in response) {
suppliers.value = response.suppliers;
} else if ('data' in response) {
suppliers.value = response.data;
} else {
suppliers.value = [];
}
} catch (e: any) {
error.value = e?.message || 'Error al cargar proveedores';
suppliers.value = [];
} finally {
loading.value = false;
}
}
return {
suppliers,
loading,
error,
fetchAllSuppliers,
};
});

View File

@ -1,6 +1,7 @@
import api from '../../../services/api';
import type {
ProductResponse,
ProductListResponse,
SingleProductResponse,
CreateProductData,
UpdateProductData,
@ -14,6 +15,18 @@ export const getProducts = async (): Promise<ProductResponse> => {
return response.data;
};
/**
* Obtener todos los productos sin paginación
* @param search - Término de búsqueda opcional para filtrar productos
*/
export const getAllProducts = async (search?: string): Promise<ProductListResponse> => {
const url = search
? `/api/products?paginate=false&search=${encodeURIComponent(search)}`
: '/api/products?paginate=false';
const response = await api.get<ProductListResponse>(url);
return response.data;
};
/**
* Obtener un producto por ID
*/
@ -50,6 +63,7 @@ export const deleteProduct = async (id: number): Promise<void> => {
export const productService = {
getProducts,
getAllProducts,
getProductById,
createProduct,
updateProduct,

View File

@ -82,6 +82,13 @@ export interface ProductResponse {
};
}
export interface ProductListResponse {
status: string;
data: {
products: Product[];
};
}
export interface SingleProductResponse {
status: string;
data: {

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { productService } from '../../products/services/productService';
const visible = defineModel<boolean>('visible');
const emit = defineEmits(['confirm', 'productSelected']);
const search = ref('');
const loading = ref(false);
const products = ref([]);
const selectedProduct = ref(null);
const productAttributes = ref<Record<string, string>>({});
const quantity = ref(1);
let debounceTimer: number | null = null;
async function fetchProducts(searchTerm?: string) {
loading.value = true;
try {
const response = await productService.getAllProducts(searchTerm);
products.value = response.data.products;
} catch (error) {
console.error('Error loading products:', error);
products.value = [];
} finally {
loading.value = false;
}
}
// Debounce para búsqueda
watch(search, (newValue) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
fetchProducts(newValue);
}, 500);
});
const filteredProducts = computed(() => products.value);
const totalProducts = computed(() => products.value.length);
const totalFiltered = computed(() => products.value.length);
function selectProduct(product) {
selectedProduct.value = product;
// Inicializar atributos dinámicos del producto
const attrs: Record<string, string> = {};
if (product.attributes) {
Object.keys(product.attributes).forEach(key => {
attrs[key] = '';
});
}
productAttributes.value = attrs;
quantity.value = 1;
}
function close() {
visible.value = false;
selectedProduct.value = null;
productAttributes.value = {};
quantity.value = 1;
}
function confirm() {
if (selectedProduct.value) {
const productToAdd = {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.name,
sku: selectedProduct.value.sku,
quantity: quantity.value,
unit_price: selectedProduct.value.suggested_sale_price || 0,
subtotal: (selectedProduct.value.suggested_sale_price || 0) * quantity.value,
attributes: productAttributes.value
};
emit('confirm', productToAdd);
}
close();
}
</script>
<template>
<Dialog v-model:visible="visible" modal :style="{ width: '700px', maxWidth: '90vw' }" header="Agregar Producto a la Solicitud" :closable="true" @hide="close">
<div class="space-y-6">
<IconField iconPosition="left">
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Buscar por Nombre o SKU..." class="w-full" />
</IconField>
<DataTable :value="filteredProducts" :loading="loading" class="w-full text-left border-collapse" :rows="10" :paginator="false">
<Column header="Imagen" bodyStyle="width:80px">
<template #body="{ data }">
<Avatar :image="data.image || ''" shape="square" size="large" class="w-10 h-10" />
</template>
</Column>
<Column header="Producto / SKU">
<template #body="{ data }">
<div class="text-sm font-semibold text-[#111418] dark:text-white">{{ data.name }}</div>
<div class="text-xs text-[#617589]">{{ data.sku }}</div>
</template>
</Column>
<Column header="Acción" bodyStyle="text-align:right">
<template #body="{ data }">
<Button
v-if="!selectedProduct || selectedProduct.id !== data.id"
icon="pi pi-plus"
label="Agregar"
class="text-primary"
text
@click="selectProduct(data)"
/>
<div v-else class="flex items-center justify-end gap-2 text-primary">
<i class="pi pi-check text-primary" />
<span class="font-bold">Seleccionado</span>
</div>
</template>
</Column>
</DataTable>
<div class="flex items-center justify-between px-2 py-2 border-t border-[#dbe0e6] dark:border-[#2d3748]">
<p class="text-xs text-[#617589]">Mostrando {{ totalFiltered }} de {{ totalProducts }} productos</p>
</div>
<div v-if="selectedProduct" class="px-2 py-4 border-t-2 border-primary/20 bg-blue-50/20 dark:bg-blue-900/5">
<h3 class="text-sm font-bold text-[#111418] dark:text-white mb-4 flex items-center gap-2">
<i class="pi pi-sliders-h text-primary text-[20px]" />
Configurar Atributos: <span class="text-primary">{{ selectedProduct.name }}</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div v-for="(options, attrName) in selectedProduct.attributes" :key="attrName">
<p class="text-xs font-bold text-[#617589] uppercase tracking-wider mb-2">{{ attrName }}</p>
<Dropdown
v-model="productAttributes[attrName]"
:options="options"
:placeholder="`Selecciona ${attrName}`"
class="w-full h-10 rounded-lg border-[#dbe0e6] dark:border-[#2d3748] dark:bg-[#2d3748] dark:text-white text-sm"
/>
</div>
<div>
<p class="text-xs font-bold text-[#617589] uppercase tracking-wider mb-2">Cantidad</p>
<div class="flex items-center h-10 border border-[#dbe0e6] dark:border-[#2d3748] rounded-lg overflow-hidden">
<Button icon="pi pi-minus" class="px-3 h-full bg-gray-50 dark:bg-[#2d3748] text-[#617589] border-r border-[#dbe0e6] dark:border-[#2d3748]" text @click="quantity = Math.max(1, quantity - 1)" />
<InputNumber v-model="quantity" :min="1" class="w-full border-none text-center text-sm font-bold dark:bg-[#1a202c]" />
<Button icon="pi pi-plus" class="px-3 h-full bg-gray-50 dark:bg-[#2d3748] text-[#617589] border-l border-[#dbe0e6] dark:border-[#2d3748]" text @click="quantity++" />
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<Button label="Cancelar" class="px-6 py-2 rounded-lg border border-[#dbe0e6] dark:border-[#2d3748] text-[#111418] dark:text-white text-sm font-bold" @click="close" />
<Button
label="Confirmar y Agregar"
class="px-6 py-2 rounded-lg bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20"
@click="confirm"
:disabled="!selectedProduct"
/>
</div>
</div>
</Dialog>
</template>

View File

@ -0,0 +1,317 @@
<template>
<div class="purchase-details-container">
<!-- Breadcrumbs -->
<Breadcrumb :home="home" :model="breadcrumbItems">
<template #item="{ item }">
<a v-if="item.route" :href="item.route" @click.prevent="router.push(item.route)"
class="text-primary hover:underline">
{{ item.label }}
</a>
<span v-else class="text-[#617589] text-sm font-bold leading-normal">
{{ item.label }}
</span>
</template>
</Breadcrumb>
<!-- Page Heading & Actions -->
<div v-if="!loading && purchase"
class="flex flex-wrap justify-between items-end gap-4 bg-white dark:bg-background-dark p-6 rounded-xl border border-[#dbe0e6] dark:border-gray-800 mb-6 shadow-sm">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<h1 class="text-[#111418] dark:text-white text-4xl font-black leading-tight tracking-[-0.033em]">{{
purchase.purchase_number }}</h1>
<span
class="px-3 py-1 bg-primary/10 text-primary text-xs font-bold rounded-full border border-primary/20">{{
PURCHASE_STATUS_MAP[purchase.status] || purchase.status }}</span>
</div>
<p class="text-[#617589] text-base font-normal leading-normal">Creada el {{
formatDate(purchase.request_date) }}
</p>
</div>
<div class="flex gap-3">
<Button icon="pi pi-check-circle" label="Convertir a compra" class="min-w-[140px] h-10 px-4" />
</div>
</div>
<div v-else-if="loading" class="flex justify-center items-center min-h-[200px]">
<span class="pi pi-spin pi-spinner text-3xl text-primary"></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Details & Items -->
<div class="lg:col-span-2 flex flex-col gap-6">
<!-- Order Information Card -->
<Card v-if="!loading && purchase"
class="rounded-xl border border-[#dbe0e6] dark:border-gray-800 overflow-hidden shadow-sm">
<template #header>
<div
class="p-4 border-b border-[#dbe0e6] dark:border-gray-800 bg-[#f8f9fa] dark:bg-gray-800/50 flex items-center gap-2">
<span class="pi pi-info-circle text-sm text-[#617589]"></span>
<span class="font-bold text-sm uppercase tracking-wider text-[#617589]">Información de la solicitud</span>
</div>
</template>
<template #content>
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div class="flex flex-col gap-1 border-l-4 border-primary pl-4">
<p class="text-[#617589] text-xs font-semibold uppercase">Información del proveedor</p>
<p class="text-[#111418] dark:text-white text-base font-bold">
{{ purchase.supplier && purchase.supplier.name ? purchase.supplier.name : 'Sin proveedor' }}
</p>
<p class="text-[#617589] text-sm">
<span v-if="purchase.supplier && purchase.supplier.contact_email">{{
purchase.supplier.contact_email }}</span>
<span
v-if="purchase.supplier && purchase.supplier.contact_email && purchase.supplier.phone_number">
| </span>
<span v-if="purchase.supplier && purchase.supplier.phone_number">{{
purchase.supplier.phone_number }}</span>
</p>
</div>
<div class="flex flex-col gap-1 border-l-4 border-gray-300 dark:border-gray-600 pl-4">
<p class="text-[#617589] text-xs font-semibold uppercase">Referencia de la solicitud de compra</p>
<p class="text-[#111418] dark:text-white text-base font-bold">{{
purchase.purchase_number }}</p>
</div>
<div class="flex flex-col gap-1 border-l-4 border-gray-300 dark:border-gray-600 pl-4">
<p class="text-[#617589] text-xs font-semibold uppercase">Notas</p>
<p class="text-[#111418] dark:text-white text-base font-bold">{{ purchase.notes }}</p>
</div>
<div class="flex flex-col gap-1 border-l-4 border-gray-300 dark:border-gray-600 pl-4">
<p class="text-[#617589] text-xs font-semibold uppercase">Fecha de Entrega</p>
<p class="text-[#111418] dark:text-white text-base font-bold">{{
formatDate(purchase.delivery_date) }}</p>
</div>
</div>
</template>
</Card>
<!-- Ordered Items Card -->
<Card v-if="!loading && purchase"
class="rounded-xl border border-[#dbe0e6] dark:border-gray-800 overflow-hidden shadow-sm">
<template #header>
<div
class="p-4 border-b border-[#dbe0e6] dark:border-gray-800 bg-[#f8f9fa] dark:bg-gray-800/50 flex justify-between items-center">
<div class="flex items-center gap-2">
<span class="pi pi-list text-sm text-[#617589]"></span>
<span class="font-bold text-sm uppercase tracking-wider text-[#617589]">Productos</span>
</div>
<span class="text-xs text-[#617589] font-medium">{{ purchase.items ? purchase.items.length :
0 }} Productos listados</span>
</div>
</template>
<template #content>
<DataTable v-if="purchase.items && purchase.items.length" :value="purchase.items"
class="w-full">
<Column field="product_id" header="ID" />
<Column field="quantity" header="Cantidad" body-class="text-center" />
<Column field="unit_price" header="Precio Unitario" body-class="text-right">
<template #body="{ data }">
<span>{{ formatCurrency(data.unit_price) }}</span>
</template>
</Column>
<Column field="subtotal" header="Subtotal" body-class="text-right">
<template #body="{ data }">
<span class="font-bold text-primary">{{ formatCurrency(data.subtotal) }}</span>
</template>
</Column>
<Column field="notes" header="Notas" />
</DataTable>
<div v-else class="text-center text-[#617589] py-6">No hay items para mostrar.</div>
</template>
</Card>
</div>
<!-- Right Column: Logistics & Financial Summary -->
<div class="flex flex-col gap-6">
<!-- Logistics Tracking Card -->
<!-- <Card class="rounded-xl border border-[#dbe0e6] dark:border-gray-800 overflow-hidden shadow-sm">
<template #header>
<div
class="p-4 border-b border-[#dbe0e6] dark:border-gray-800 bg-[#f8f9fa] dark:bg-gray-800/50 flex items-center gap-2">
<span class="pi pi-truck text-sm text-[#617589]"></span>
<span class="font-bold text-sm uppercase tracking-wider text-[#617589]">Logistics</span>
</div>
</template>
<div class="p-6">
<div class="mb-6">
<p class="text-[#617589] text-xs font-semibold uppercase mb-2">Delivery Status</p>
<div class="flex items-center gap-3">
<div class="w-full bg-[#f0f2f4] dark:bg-gray-800 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width: 40%"></div>
</div>
<span class="text-xs font-bold text-primary whitespace-nowrap">In Transit</span>
</div>
<p class="text-[#617589] text-[11px] mt-2 italic">Estimated delivery: Oct 31, 2023</p>
</div>
<div>
<p class="text-[#617589] text-xs font-semibold uppercase mb-2">Destination</p>
<div class="flex items-start gap-3">
<span class="pi pi-map-marker text-primary mt-1"></span>
<div class="text-sm">
<p class="font-bold">Main Distribution Center</p>
<p class="text-[#617589]">Warehouse B, Dock 4</p>
<p class="text-[#617589]">882 Logistics Way</p>
<p class="text-[#617589]">Chicago, IL 60601</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-[#dbe0e6] dark:border-gray-800">
<div class="rounded-lg overflow-hidden h-32 bg-gray-100 relative group">
<div class="absolute inset-0 bg-primary/10 flex items-center justify-center">
<span class="pi pi-map-marker text-3xl text-primary animate-bounce"></span>
</div>
<img class="w-full h-full object-cover grayscale"
alt="Map view showing delivery destination"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuCm1mh8AyZ_GOLrQWGg6_fQE6_m-PpLiUyQDhDK_cooMkYT8QRVfPsWPGyLAICz7vKq7tVhRDGTJk9e0XjZZ-91XpsMoguksdn7YEB_WJflyuZvPsCqcxApGFkkC11P9N1iPNw6YcDM5VPuF1AQVB1JpyQijgQIZTsnuK5A1Fm6--evPpKckIawOpHRj8FBB8wh2YQM9fZ5Zl45ex3f4q9fTYU3k0k0Bgu6pG2hZC31PEqu2QZgJp3-LNsWIozBAEJMjrOjLzRnR7aj" />
</div>
</div>
</div>
</Card> -->
<!-- Financial Summary Card -->
<Card class="rounded-xl border-2 border-primary overflow-hidden shadow-lg">
<template #header>
<div
class="p-4 border-b border-[#dbe0e6] dark:border-gray-800 bg-primary/5 flex items-center gap-2">
<span class="pi pi-credit-card text-sm text-primary"></span>
<span class="font-bold text-sm uppercase tracking-wider text-primary">Resumen
Financiero</span>
</div>
</template>
<template #content>
<div class="p-6 flex flex-col gap-4">
<div class="flex justify-between items-center">
<span class="text-[#617589] text-sm">Subtotal</span>
<span class="font-bold text-primary">{{ formatCurrency(purchase?.total_amount ? purchase.total_amount : 0) }}</span>
</div>
<!-- <div class="flex justify-between items-center">
<span class="text-[#617589] text-sm">Impuesto</span>
<span class="font-bold text-primary">{{ formatCurrency(summary.tax) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-[#617589] text-sm">Envío</span>
<span class="font-bold text-primary">{{ formatCurrency(summary.shipping) }}</span>
</div> -->
<div class="flex justify-between items-center pt-4 border-t border-[#dbe0e6] dark:border-gray-800">
<span class="text-[#617589] text-sm font-semibold uppercase">Total</span>
<span class="font-bold text-xl text-primary">{{ formatCurrency(purchase?.total_amount ? purchase.total_amount : 0) }}</span>
</div>
</div>
</template>
<div class="p-4 bg-[#f8f9fa] dark:bg-gray-800/50 text-center">
<p class="text-[11px] text-[#617589] font-medium leading-relaxed">
La factura final puede variar según el peso exacto del envío y los impuestos locales en el momento de la entrega.
</p>
</div>
</Card>
</div>
</div>
<!-- Footer Audit Log -->
<div
class="mt-8 flex items-center justify-between text-[#617589] text-[11px] font-medium uppercase tracking-widest px-4">
<div class="flex items-center gap-4">
<span>Last modified: Oct 25, 2023 10:42 AM</span>
<span class="size-1 rounded-full bg-[#dbe0e6]"></span>
<span>Revision: v2.1</span>
</div>
<div class="flex items-center gap-2">
<span class="pi pi-lock text-xs"></span>
<span>Secure Transaction</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Card from 'primevue/card';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Breadcrumb from 'primevue/breadcrumb';
import { useRouter } from 'vue-router';
import { purchaseServices } from '../services/purchaseServices';
import type { PurchaseDetailResponse, PurchaseDetailData, PurchaseItem } from '../types/purchases';
import { useRoute } from 'vue-router';
import { PURCHASE_STATUS_MAP } from '../types/purchases.d';
const items = ref([
{
product: 'Advanced Logic Controller v4',
sku: 'ALC-V4-99',
qty: 10,
unitPrice: 450,
tax: 8.5,
lineTotal: 4882.5,
},
{
product: 'Industrial Power Supply 12V',
sku: 'PWR-IND-12',
qty: 25,
unitPrice: 85,
tax: 8.5,
lineTotal: 2305.63,
},
{
product: 'Ethernet Patch Cable Cat6 (2m)',
sku: 'CAB-CAT6-2M',
qty: 100,
unitPrice: 4.5,
tax: 0,
lineTotal: 450,
},
{
product: 'Sensor Mount Bracket A',
sku: 'BRA-SENS-A',
qty: 50,
unitPrice: 12,
tax: 8.5,
lineTotal: 651,
},
]);
const summary = ref({
subtotal: 7625,
tax: 664.13,
shipping: 0,
total: 8289.13,
});
const router = useRouter();
const breadcrumbItems = [
{ label: 'Inicio', route: '/' },
{ label: 'Compras', route: '/purchases/requests' },
{ label: 'Órdenes', route: '/purchases/requests' },
{ label: 'Detalle' }
];
const home = { icon: 'pi pi-home', route: '/' };
const route = useRoute();
const purchase = ref<PurchaseDetailData | null>(null);
const loading = ref(true);
onMounted(async () => {
loading.value = true;
try {
const id = Number(route.params.id);
if (id) {
const response: PurchaseDetailResponse = await purchaseServices.getPurchaseById(id);
console.log('Fetched purchase details:', response);
purchase.value = response.data;
}
} finally {
loading.value = false;
}
});
function formatCurrency(value: number | string) {
const num = typeof value === 'string' ? parseFloat(value) : value;
return num.toLocaleString('en-US', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 2,
});
}
function formatDate(dateStr: string) {
if (!dateStr) return '';
const d = new Date(dateStr.replace(' ', 'T'));
return d.toLocaleDateString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
</script>

View File

@ -0,0 +1,295 @@
<template>
<div class="purchase-form-container">
<Toast />
<Breadcrumb :home="home" :model="breadcrumbItems" class="mb-4">
<template #item="{ item }">
<a v-if="item.route" :href="item.route" @click.prevent="router.push(item.route)"
class="text-primary hover:underline">
{{ item.label }}
</a>
<span v-else class="text-[#111418] dark:text-white text-sm font-bold leading-normal">
{{ item.label }}
</span>
</template>
</Breadcrumb>
<div class="flex flex-wrap justify-between gap-3 py-6 border-b border-[#dbe0e6] dark:border-[#2d3748] mb-8">
<div class="flex flex-col gap-1">
<h1 class="text-[#111418] dark:text-white text-3xl font-black leading-tight tracking-[-0.033em]">Nueva
Solicitud
de Compra</h1>
</div>
</div>
<form class="space-y-8" @submit.prevent="onSubmit">
<!-- Información General -->
<Card
class="bg-white dark:bg-[#1a202c] rounded-xl shadow-sm border border-[#dbe0e6] dark:border-[#2d3748] overflow-hidden">
<template #header>
<div
class="flex items-center gap-3 px-6 py-4 border-b border-[#dbe0e6] dark:border-[#2d3748] bg-gray-50 dark:bg-[#232a35]">
<i class="pi pi-info-circle text-primary" />
<h2 class="text-[#111418] dark:text-white text-lg font-bold">Información General</h2>
</div>
</template>
<template #content>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<label class="text-[#111418] dark:text-gray-200 text-sm font-semibold">Proveedor</label>
<Dropdown v-model="form.supplier_id" :options="suppliers" optionLabel="name"
optionValue="id" placeholder="Seleccionar proveedor..."
class="w-full h-12 text-sm" />
</div>
</div>
<div class="flex flex-col gap-2 mt-6">
<label class="text-[#111418] dark:text-gray-200 text-sm font-semibold">Notas</label>
<Textarea v-model="form.notes" autoResize rows="3" class="w-full min-h-20 text-sm"
placeholder="Información adicional sobre la orden..." />
</div>
</div>
</template>
</Card>
<!-- Items de la Solicitud -->
<Card
class="bg-white dark:bg-[#1a202c] rounded-xl shadow-sm border border-[#dbe0e6] dark:border-[#2d3748] overflow-hidden">
<template #header>
<div
class="flex items-center justify-between px-6 py-4 border-b border-[#dbe0e6] dark:border-[#2d3748] bg-gray-50 dark:bg-[#232a35]">
<div class="flex items-center gap-3">
<i class="pi pi-box text-primary" />
<h2 class="text-[#111418] dark:text-white text-lg font-bold">Items de la Solicitud</h2>
</div>
<Button icon="pi pi-plus" label="Agregar Producto"
class="bg-primary text-white px-4 py-2 rounded-lg text-sm font-bold" @click="handleAddProduct"
type="button" />
</div>
</template>
<template #content>
<DataTable :value="form.items" class="w-full min-w-[900px]">
<Column field="product" header="Producto">
<template #body="{ data }">
<div>
<div class="font-medium text-[#111418] dark:text-white">{{ data.product_name }}</div>
<div class="text-xs text-[#617589]">{{ data.sku }}</div>
</div>
</template>
</Column>
<Column field="attributes" header="Atributos" :body="attributesBody" />
<Column field="quantity" header="Cantidad">
<template #body="{ data }">
<InputNumber v-model="data.quantity" :min="1" class="w-full" />
</template>
</Column>
<Column field="unit_price" header="Precio Unitario">
<template #body="{ data }">
<InputNumber v-model="data.unit_price" mode="currency" currency="MXN" locale="es-MX"
class="w-full" />
</template>
</Column>
<Column field="subtotal" header="Subtotal">
<template #body="{ data }">
<span class="font-bold">{{ formatCurrency(calculateItemSubtotal(data)) }}</span>
</template>
</Column>
<Column field="notes" header="Notas">
<template #body="{ data }">
<InputText v-model="data.notes" class="w-full min-w-[150px]"
placeholder="Nota de item" />
</template>
</Column>
<Column header="Acciones" style="text-align:center; width:100px">
<template #body="{ index }">
<Button icon="pi pi-trash" class="text-gray-400 hover:text-red-500"
text @click="removeItem(index)" type="button" />
</template>
</Column>
</DataTable>
<div class="flex justify-end bg-gray-50 dark:bg-[#232a35] px-6 py-4">
<span class="text-base font-bold text-[#111418] dark:text-white mr-4">Total Estimado:</span>
<span class="text-xl font-black text-primary">{{ formatCurrency(total) }}</span>
</div>
</template>
</Card>
<ProductsModal v-model:visible="showProductModal" @confirm="handleProductConfirm" />
<div class="flex items-center justify-end gap-4 py-10 border-t border-[#dbe0e6] dark:border-[#2d3748]">
<Button label="Cancelar"
class="px-8 py-3 rounded-lg border border-[#dbe0e6] dark:border-[#2d3748] text-[#111418] dark:text-white font-bold"
type="button" @click="onCancel" outlined />
<Button label="Enviar Solicitud" icon="pi pi-send"
class="px-10 py-3 rounded-lg bg-primary text-white font-bold shadow-lg shadow-primary/20"
type="submit" />
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useSupplierStore } from '../../catalog/stores/supplierStore';
import { purchaseServices } from '../services/purchaseServices';
import type { CreatePurchaseData, CreatePurchaseItemData } from '../types/purchases';
import type { PurchaseFormItem } from '../types/purchaseForm';
import { useToast } from 'primevue/usetoast';
import Card from 'primevue/card';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Textarea from 'primevue/textarea';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Toast from 'primevue/toast';
import Breadcrumb from 'primevue/breadcrumb';
import ProductsModal from './ProductsModal.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
const breadcrumbItems = [
{ label: 'Compras', route: '/purchases/requests' },
{ label: 'Solicitudes', route: '/purchases/requests' },
{ label: 'Crear' }
];
const home = { icon: 'pi pi-home', route: '/' };
// Proveedores desde el store global
const supplierStore = useSupplierStore();
const { suppliers } = storeToRefs(supplierStore);
onMounted(() => {
if (!suppliers.value.length) supplierStore.fetchAllSuppliers();
});
const form = ref({
purchase_number: '',
supplier_id: null,
request_date: '',
delivery_date: '',
notes: '',
items: [] as PurchaseFormItem[],
});
function calculateItemSubtotal(item: PurchaseFormItem): number {
return (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
const total = computed(() => {
return form.value.items.reduce((sum, item) => {
return sum + calculateItemSubtotal(item);
}, 0);
});
function formatCurrency(value: number) {
return value.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2 });
}
function onCancel() {
// Navegar o limpiar formulario
router.replace('/purchases/requests');
}
async function onSubmit() {
try {
// Validación básica
if (!form.value.supplier_id) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'Por favor seleccione un proveedor',
life: 3000
});
return;
}
if (form.value.items.length === 0) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'Por favor agregue al menos un producto',
life: 3000
});
return;
}
// Transformar items del formulario al formato del API
const items: CreatePurchaseItemData[] = form.value.items.map(item => ({
product_id: item.product_id,
quantity: item.quantity,
unit_price: Number(item.unit_price),
subtotal: calculateItemSubtotal(item),
notes: item.notes || '',
attributes: item.attributes || {}
}));
// Preparar datos para enviar
const purchaseData: CreatePurchaseData = {
supplier_id: form.value.supplier_id,
notes: form.value.notes || '',
total_amount: total.value,
items
};
// Enviar solicitud
const response = await purchaseServices.createPurchaseRequest(purchaseData);
// Mostrar mensaje de éxito
toast.add({
severity: 'success',
summary: 'Solicitud Creada',
detail: `Solicitud ${response.data.purchase_number} creada exitosamente`,
life: 3000
});
// Redirigir al listado
router.push('/purchases/requests');
} catch (error) {
console.error('Error creating purchase:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo crear la solicitud. Intente nuevamente',
life: 3000
});
}
}
function handleAddProduct(): void {
showProductModal.value = true;
}
function handleProductConfirm(productData: PurchaseFormItem): void {
// Agregar el producto seleccionado al formulario
if (productData) {
form.value.items.push({
product_id: productData.product_id,
product_name: productData.product_name,
sku: productData.sku,
quantity: productData.quantity,
unit_price: productData.unit_price,
subtotal: '0', // Se calculará dinámicamente
notes: productData.notes || '',
attributes: productData.attributes || {}
});
}
showProductModal.value = false;
}
const showProductModal = ref(false);
function removeItem(index: number) {
form.value.items.splice(index, 1);
}
// DataTable custom bodies
function attributesBody(row: any) {
if (!row.attributes || typeof row.attributes !== 'object') return '';
const attrs = Object.entries(row.attributes)
.filter(([, value]) => value)
.map(([key, value]) => `<span class='px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[10px] mr-1'>${key}: ${value}</span>`);
return attrs.join('');
}
</script>

View File

@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { purchaseServices } from '../services/purchaseServices';
import type { Purchase } from '../types/purchases';
import { PURCHASE_STATUS_MAP } from '../types/purchases.d';
import Card from 'primevue/card';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Paginator from 'primevue/paginator';
import Toast from 'primevue/toast';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import ConfirmDialog from 'primevue/confirmdialog';
import { useConfirm } from 'primevue/useconfirm';
const purchaseOrders = ref<Purchase[]>([]);
const page = ref(1);
const rows = ref(10);
const totalRecords = ref(0);
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const fetchPurchases = async () => {
try {
const response = await purchaseServices.getPurchases();
purchaseOrders.value = response.data;
totalRecords.value = response.total;
// Si tu backend soporta paginación, puedes actualizar page, rows, etc. aquí
} catch (e) {
// Manejo de error opcional
}
};
onMounted(() => {
fetchPurchases();
});
const onPageChange = (event: any) => {
page.value = Math.floor(event.first / event.rows) + 1;
rows.value = event.rows;
};
function goToDetails(id: string | number) {
router.push({ name: 'PurchaseDetails', params: { id } });
}
function goToCreate() {
router.push({ name: 'PurchaseCreate' });
}
function getStatusSeverity(status: string): 'success' | 'info' | 'warning' | 'danger' {
const severityMap: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
'0': 'warning', // Pendiente
'1': 'danger', // Rechazada
'2': 'success', // Aceptada
'3': 'info', // Convertida en compra
'4': 'success', // Ingresada a inventario
};
return severityMap[status] || 'info';
}
async function approvePurchase(id: string | number) {
confirm.require({
message: '¿Está seguro de que desea aceptar esta orden de compra?',
header: 'Confirmar Aceptación',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sí, Aceptar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const response = await purchaseServices.updatePurchaseStatus(Number(id), '2');
const index = purchaseOrders.value.findIndex(p => p.id === Number(id));
if (index !== -1) {
purchaseOrders.value[index] = response.data;
}
toast.add({ severity: 'success', summary: 'Orden Aceptada', detail: 'La orden de compra ha sido aceptada exitosamente', life: 3000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo aceptar la orden de compra', life: 3000 });
}
}
});
}
async function rejectPurchase(id: string | number) {
confirm.require({
message: '¿Está seguro de que desea rechazar esta orden de compra? Esta acción no se puede deshacer.',
header: 'Confirmar Rechazo',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sí, Rechazar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const response = await purchaseServices.updatePurchaseStatus(Number(id), '1');
const index = purchaseOrders.value.findIndex(p => p.id === Number(id));
if (index !== -1) {
purchaseOrders.value[index] = response.data;
}
toast.add({ severity: 'warn', summary: 'Orden Rechazada', detail: 'La orden de compra ha sido rechazada', life: 3000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo rechazar la orden de compra', life: 3000 });
}
}
});
}
async function convertToPurchase(id: string | number) {
confirm.require({
message: '¿Está seguro de que desea convertir esta solicitud en una orden de compra?',
header: 'Confirmar Conversión',
icon: 'pi pi-shopping-cart',
acceptLabel: 'Sí, Convertir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const response = await purchaseServices.updatePurchaseStatus(Number(id), '3');
const index = purchaseOrders.value.findIndex(p => p.id === Number(id));
if (index !== -1) {
purchaseOrders.value[index] = response.data;
}
toast.add({ severity: 'success', summary: 'Convertida en Compra', detail: 'La solicitud ha sido convertida en orden de compra exitosamente', life: 3000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo convertir la solicitud en compra', life: 3000 });
}
}
});
}
</script>
<template>
<div class="space-y-6">
<Toast />
<ConfirmDialog />
<!-- Page Heading & Actions -->
<div class="flex flex-wrap justify-between items-end gap-3 mb-8">
<div>
<p class="text-4xl font-black leading-tight tracking-[-0.033em]">Órdenes de Compra</p>
<p class="text-gray-500 mt-1">Administra y rastrea los ciclos de compras en todos los departamentos.</p>
</div>
<div class="flex gap-3">
<!-- <Button icon="pi pi-download" label="Exportar CSV" outlined /> -->
<Button icon="pi pi-plus" label="Nueva Orden" @click="goToCreate" />
</div>
</div>
<Card>
<template #content>
<!-- Data Table -->
<DataTable :value="purchaseOrders" stripedRows responsiveLayout="scroll" class="p-datatable-sm">
<Column field="purchase_number" header="Folio" />
<Column field="supplier.name" header="Proveedor" />
<Column field="request_date" header="Fecha de Solicitud">
<template #body="{ data }">
{{ new Date(data.request_date).toLocaleDateString() }}
</template>
</Column>
<Column field="total_amount" header="Monto Total">
<template #body="{ data }">
{{ new Intl.NumberFormat('es-MX', {
style: 'currency', currency: 'MXN'
}).format(data.total_amount) }}
</template>
</Column>
<Column header="Estado">
<template #body="{ data }">
<Tag :value="PURCHASE_STATUS_MAP[data.status] || data.status"
:severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column header="Acciones" bodyStyle="text-align: right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button v-if="data.status === '0'" icon="pi pi-check" severity="success" text rounded
@click="approvePurchase(data.id)" v-tooltip.top="'Aprobar'" />
<Button v-if="data.status === '0'" icon="pi pi-times" severity="danger" text rounded
@click="rejectPurchase(data.id)" v-tooltip.top="'Rechazar'" />
<Button v-if="data.status === '2'" icon="pi pi-shopping-cart" severity="success" text rounded
@click="convertToPurchase(data.id)" v-tooltip.top="'Convertir en Compra'" />
<Button icon="pi pi-eye" severity="info" text rounded
@click="goToDetails(data.id)" v-tooltip.top="'Ver Detalles'" />
</div>
</template>
</Column>
</DataTable>
<div class="mt-4">
<Paginator :first="(page - 1) * rows" :rows="rows" :totalRecords="totalRecords"
:rowsPerPageOptions="[10, 20, 50]" @page="onPageChange" />
</div>
</template>
</Card>
</div>
</template>

View File

@ -0,0 +1,52 @@
import api from '../../../services/api';
import type { PurchasePaginatedResponse, PurchaseDetailResponse, CreatePurchaseData, CreatePurchaseResponse, UpdatePurchaseStatusResponse } from '../types/purchases';
const purchaseServices = {
async getPurchases(): Promise<PurchasePaginatedResponse> {
try {
const response = await api.get('/api/purchases');
console.log('📦 Purchases response:', response);
return response.data;
} catch (error) {
console.error('❌ Error fetching purchases:', error);
throw error;
}
},
async getPurchaseById(purchaseId: number): Promise<PurchaseDetailResponse> {
try {
const response = await api.get(`/api/purchases/${purchaseId}`);
console.log(`📦 Purchase with ID ${purchaseId} response:`, response.data);
return response.data;
} catch (error) {
console.error(`❌ Error fetching purchase with ID ${purchaseId}:`, error);
throw error;
}
},
async createPurchaseRequest(data: CreatePurchaseData): Promise <CreatePurchaseResponse> {
try {
const response = await api.post('/api/purchases', data);
console.log('📦 Create Purchase response:', response.data);
return response.data;
} catch (error) {
console.error('❌ Error creating purchase:', error);
throw error;
}
},
async updatePurchaseStatus(purchaseId: number, status: string): Promise<UpdatePurchaseStatusResponse> {
try {
const response = await api.patch(`/api/purchases/${purchaseId}`, { status });
console.log(`✅ Updated status for purchase with ID ${purchaseId} to ${status}`);
return response.data;
} catch (error) {
console.error(`❌ Error updating status for purchase with ID ${purchaseId}:`, error);
throw error;
}
}
}
export { purchaseServices };

View File

@ -0,0 +1,10 @@
export interface PurchaseFormItem {
product_id: number;
product_name: string;
sku: string;
quantity: number;
unit_price: number | string;
subtotal?: string;
notes?: string;
attributes?: Record<string, any>;
}

View File

@ -0,0 +1,10 @@
export interface PurchaseFormItem {
product_id: number;
product_name: string;
sku: string;
quantity: number;
unit_price: number | string;
subtotal?: string;
notes?: string;
attributes?: Record<string, any>;
}

View File

@ -0,0 +1,102 @@
import type { Supplier } from '../../catalog/types/suppliers';
export interface Purchase {
id: number;
purchase_number: string;
supplier_id: number;
status: string;
notes: string;
total_amount: number;
request_date: string;
delivery_date: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
supplier: Supplier | null;
}
export interface PaginationLink {
url: string | null;
label: string;
active: boolean;
}
export interface PurchasePaginatedResponse {
current_page: number;
data: Purchase[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: PaginationLink[];
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}
export interface PurchaseItem {
id: number;
purchase_id: number;
product_id: number;
quantity: number;
unit_price: string;
subtotal: string;
notes: string;
attributes: Record<string, any>;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface PurchaseDetailData extends Purchase {
items: PurchaseItem[];
supplier: Supplier;
}
export interface PurchaseDetailResponse {
data: PurchaseDetailData;
}
// Mapeo de estatus de solicitud de compra (enum backend)
export interface PurchaseStatusMap {
[key: string]: string;
}
export const PURCHASE_STATUS_MAP: PurchaseStatusMap = {
'0': 'Pendiente',
'1': 'Rechazada',
'2': 'Aceptada',
'3': 'Convertida en compra',
'4': 'Ingresada a inventario',
};
export interface CreatePurchaseItemData {
product_id: number;
quantity: number;
unit_price: number;
subtotal: number;
notes: string;
attributes: Record<string, any>;
}
export interface CreatePurchaseData {
supplier_id: number;
notes: string;
total_amount: number;
items: CreatePurchaseItemData[];
}
export interface CreatedPurchaseData extends Omit<Purchase, 'supplier'> {
items: PurchaseItem[];
}
export interface CreatePurchaseResponse {
data: CreatedPurchaseData;
}
export interface UpdatePurchaseStatusResponse {
data: Purchase;
}

View File

@ -25,7 +25,9 @@ import Departments from '../modules/rh/components/Departments.vue';
import '../modules/catalog/components/suppliers/Suppliers.vue';
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
import Purchases from '../modules/purchases/components/Purchases.vue';
import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue';
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
const routes: RouteRecordRaw[] = [
{
path: '/login',
@ -162,6 +164,31 @@ const routes: RouteRecordRaw[] = [
},
]
},
{
path: 'purchases',
name: 'Purchases',
meta: {
title: 'Compras',
requiresAuth: true
},
children: [
{
path: 'requests',
name: 'PurchaseRequests',
component: Purchases,
},
{
path: ':id',
name: 'PurchaseDetails',
component: PurchaseDetails,
},
{
path: 'create',
name: 'PurchaseCreate',
component: PurchaseForm,
},
]
},
{
path: 'products',
name: 'Products',