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:
parent
4a624f490c
commit
71454dda61
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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" />
|
||||
|
||||
40
src/modules/catalog/stores/supplierStore.ts
Normal file
40
src/modules/catalog/stores/supplierStore.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@ -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,
|
||||
|
||||
7
src/modules/products/types/product.d.ts
vendored
7
src/modules/products/types/product.d.ts
vendored
@ -82,6 +82,13 @@ export interface ProductResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
products: Product[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SingleProductResponse {
|
||||
status: string;
|
||||
data: {
|
||||
|
||||
158
src/modules/purchases/components/ProductsModal.vue
Normal file
158
src/modules/purchases/components/ProductsModal.vue
Normal 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>
|
||||
|
||||
317
src/modules/purchases/components/PurchaseDetails.vue
Normal file
317
src/modules/purchases/components/PurchaseDetails.vue
Normal 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>
|
||||
295
src/modules/purchases/components/PurchaseForm.vue
Normal file
295
src/modules/purchases/components/PurchaseForm.vue
Normal 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>
|
||||
195
src/modules/purchases/components/Purchases.vue
Normal file
195
src/modules/purchases/components/Purchases.vue
Normal 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>
|
||||
52
src/modules/purchases/services/purchaseServices.ts
Normal file
52
src/modules/purchases/services/purchaseServices.ts
Normal 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 };
|
||||
10
src/modules/purchases/types/purchaseForm.d.ts
vendored
Normal file
10
src/modules/purchases/types/purchaseForm.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
10
src/modules/purchases/types/purchaseForm.ts
Normal file
10
src/modules/purchases/types/purchaseForm.ts
Normal 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>;
|
||||
}
|
||||
102
src/modules/purchases/types/purchases.d.ts
vendored
Normal file
102
src/modules/purchases/types/purchases.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user