feat: redesign purchase page with improved UI/UX and responsive layout

This commit is contained in:
2026-05-28 17:31:26 -05:00
parent d86919e930
commit e2604a1837

View File

@@ -1,147 +1,316 @@
<template>
<v-container>
<v-form ref="purchase" v-model="valid" @change="onFormChange">
<v-row>
<v-col>
<v-autocomplete
v-model="purchase.customer"
:items="filteredClients"
:search="client_search"
no-data-text="No se hallaron clientes"
item-title="name"
item-value="id"
@update:model-value="onFormChange"
label="Cliente"
:rules="[rules.required]"
required
class="mr-4"
></v-autocomplete>
<!--<v-btn color="primary" @click="openModal">Agregar Cliente</v-btn>-->
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer"/>
</v-col>
<v-col lg="4">
<v-text-field
v-model="purchase.date"
label="Fecha"
type="datetime-local"
:rules="[rules.required]"
required
readonly
></v-text-field>
</v-col>
</v-row>
<v-textarea
v-model="purchase.notes"
label="Notas"
rows="2"
></v-textarea>
<v-divider></v-divider>
<v-container>
<v-toolbar>
<v-toolbar-title secondary>Productos</v-toolbar-title>
</v-toolbar>
<v-container v-for="(line, index) in purchase.saleline_set" :key="line.id">
<v-row>
<v-col
lg="9">
<v-autocomplete
v-model="line.product"
:items="filteredProducts"
:search="product_search"
@update:modelValue="onProductChange(index)"
no-data-text="No se hallaron productos"
item-title="name"
item-value="id"
item-subtitle="Price"
label="Producto"
:rules="[rules.required]"
required
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.name" :subtitle="formatPrice(item.raw.price)"></v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col
lg="2"
>
<v-text-field
v-model.number="line.quantity"
label="Cantidad"
type="number"
:rules="[rules.required,rules.positive]"
<v-container fluid class="pa-4 pa-md-6">
<v-form ref="purchase" v-model="valid" @change="onFormChange">
<!-- Encabezado -->
<v-sheet class="page-header d-flex align-center pa-4 pa-md-6 mb-4 rounded-lg">
<v-icon start size="40" color="white" class="mr-3">mdi-cart-plus</v-icon>
<div>
<h1 class="text-h5 text-md-h4 font-weight-bold text-white mb-0">Nueva Compra</h1>
<p class="text-body-2 text-white text-medium-emphasis mb-0">Registra una nueva venta en el sistema</p>
</div>
</v-sheet>
<!-- Loading -->
<template v-if="loading">
<v-skeleton-loader
class="mb-4 rounded-lg"
type="card-heading, list-item-two-line, list-item-two-line"
></v-skeleton-loader>
</template>
<template v-else>
<!-- Card: Información del Cliente -->
<v-card class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="primary" size="36">mdi-account</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Información del Cliente</v-card-title>
<v-card-subtitle>Datos básicos de la compra</v-card-subtitle>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-autocomplete
v-model="purchase.customer"
:items="clients"
:search="client_search"
no-data-text="No se hallaron clientes"
item-title="name"
item-value="id"
@update:model-value="onFormChange"
label="Cliente"
:rules="[rules.required]"
required
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model.number="line.unit_price"
label="Precio"
type="number"
:rules="[rules.required]"
prefix="$"
required
readonly
></v-text-field>
</v-col>
<v-col>
<v-text-field
v-model="line.measuring_unit"
label="UdM"
persistent-placeholder="true"
readonly
></v-text-field>
</v-col>
<v-col>
<v-text-field
type="number"
:value="calculateSubtotal(line)"
label="Subtotal"
prefix="$"
readonly
disable
persistent-placeholder="true"
></v-text-field>
</v-col>
<v-col>
<v-btn @click="removeLine(index)" color="red">Eliminar</v-btn>
</v-col>
</v-row>
<v-alert type="warning" :duration="2000" closable v-model="show_alert_lines">
No se puede eliminar la única línea.
</v-alert>
</v-container>
<v-btn @click="addLine" color="blue">Agregar</v-btn>
</v-container>
<v-divider></v-divider>
<v-text-field
:value="calculateTotal"
label="Total"
prefix="$"
readonly
persistent-placeholder="true"
></v-text-field>
<v-container v-if="calculateTotal > 0">
<v-select
:items="payment_methods"
v-model="purchase.payment_method"
item-title="text"
item-value="value"
label="Pago en"
:rules="[rules.required]"
required
></v-select>
<v-btn @click="openCasherModal" v-if="purchase.payment_method === 'CASH'">Calcular Devuelta</v-btn>
<CasherModal :total_purchase="calculateTotal" ref="casherModal"</CasherModal>
</v-container>
<v-btn @click="submit" color="green">Comprar</v-btn>
<v-alert type="error" :duration="2000" closable v-model="show_alert_purchase">
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
></v-autocomplete>
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer" />
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="purchase.date"
label="Fecha"
type="datetime-local"
:rules="[rules.required]"
required
readonly
variant="outlined"
density="comfortable"
hide-details="auto"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-btn
variant="tonal"
color="primary"
size="small"
class="mt-1"
prepend-icon="mdi-plus"
@click="openModal"
>
Nuevo Cliente
</v-btn>
</v-col>
</v-row>
<v-row class="mt-0">
<v-col cols="12">
<v-textarea
v-model="purchase.notes"
label="Notas"
rows="2"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Card: Productos -->
<v-card class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="green-darken-1" size="36">mdi-package-variant-closed</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Productos</v-card-title>
<v-card-subtitle>Agrega los productos de la compra</v-card-subtitle>
</v-card-item>
<v-divider></v-divider>
<!-- Encabezados de columnas (solo desktop) -->
<div class="d-none d-md-flex px-6 pt-3 text-caption font-weight-bold text-grey">
<div class="flex-grow-1 flex-shrink-1" style="min-width:0">Producto</div>
<div class="mx-2 text-center" style="width:100px; flex-shrink:0">Cantidad</div>
<div class="mx-2 text-end" style="width:110px; flex-shrink:0">Precio</div>
<div class="mx-2 text-end" style="width:110px; flex-shrink:0">Subtotal</div>
<div style="width:40px; flex-shrink:0"></div>
</div>
<v-card-text class="pa-0 pa-md-4">
<div
v-for="(line, index) in purchase.saleline_set"
:key="line.id"
class="product-line pa-3 pa-md-2"
>
<v-row no-gutters align="start" class="flex-md-nowrap">
<!-- Producto -->
<v-col cols="12" md class="mb-2 mb-md-0">
<v-autocomplete
v-model="line.product"
:items="products"
:search="product_search"
@update:modelValue="onProductChange(index)"
no-data-text="No se hallaron productos"
item-title="name"
item-value="id"
label="Producto"
:rules="[rules.required]"
required
variant="outlined"
density="compact"
hide-details="auto"
class="product-select"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.name" :subtitle="formatPrice(item.raw.price)"></v-list-item>
</template>
</v-autocomplete>
</v-col>
<!-- Cantidad -->
<v-col cols="4" md="auto" class="pe-1">
<v-text-field
v-model.number="line.quantity"
label="Cant."
type="number"
:rules="[rules.required, rules.positive]"
required
variant="outlined"
density="compact"
hide-details="auto"
min="0"
class="quantity-field"
></v-text-field>
</v-col>
<!-- Precio -->
<v-col cols="4" md="auto" class="px-1">
<v-text-field
v-model.number="line.unit_price"
label="Precio"
type="number"
:rules="[rules.required]"
prefix="$"
required
readonly
variant="outlined"
density="compact"
hide-details="auto"
class="price-field"
></v-text-field>
</v-col>
<!-- Subtotal + UdM -->
<v-col cols="4" md="auto" class="ps-1">
<v-text-field
type="number"
:value="calculateSubtotal(line)"
label="Subtotal"
prefix="$"
readonly
variant="outlined"
density="compact"
hide-details="auto"
class="subtotal-field"
></v-text-field>
<div class="text-caption text-grey text-center mt-1 d-md-none">{{ line.measuring_unit || 'Ud' }}</div>
</v-col>
<!-- Eliminar + UdM desktop -->
<v-col cols="12" md="auto" class="d-flex align-center mt-2 mt-md-0 ps-md-2">
<v-btn
@click="removeLine(index)"
color="error"
variant="text"
icon="mdi-delete"
size="small"
density="comfortable"
class="flex-shrink-0"
></v-btn>
<span class="text-caption text-grey ms-2 d-none d-md-inline">{{ line.measuring_unit || 'Ud' }}</span>
</v-col>
</v-row>
</div>
<v-alert
type="warning"
closable
v-model="show_alert_lines"
density="compact"
variant="tonal"
class="ma-3"
>
No se puede eliminar la única línea.
</v-alert>
<v-divider class="my-2"></v-divider>
<v-btn
@click="addLine"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
class="mt-2"
>
Agregar Producto
</v-btn>
</v-card-text>
</v-card>
<!-- Card: Resumen y Pago -->
<v-card v-if="calculateTotal > 0" class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="green-darken-2" size="36">mdi-credit-card</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Resumen y Pago</v-card-title>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-select
:items="payment_methods"
v-model="purchase.payment_method"
item-title="text"
item-value="value"
label="Método de Pago"
:rules="[rules.required]"
required
variant="outlined"
density="comfortable"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="12" md="6" class="text-center text-md-end">
<div class="text-body-2 text-grey mb-1">Total de la compra</div>
<div class="total-amount">{{ formatPrice(calculateTotal) }}</div>
<v-btn
v-if="purchase.payment_method === 'CASH'"
@click="openCasherModal"
color="orange-darken-2"
variant="tonal"
prepend-icon="mdi-cash"
size="small"
class="mt-1"
>
Calcular Devuelta
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Botón Comprar -->
<div class="d-flex flex-column flex-sm-row justify-space-between align-center ga-4 mt-6">
<div class="text-caption text-grey">
<v-icon start size="small" color="grey">mdi-information</v-icon>
Todos los campos marcados con * son obligatorios
</div>
<v-btn
@click="submit"
color="green-darken-1"
size="x-large"
prepend-icon="mdi-cart-check"
variant="elevated"
class="px-8 submit-btn"
:disabled="calculateTotal <= 0"
>
Comprar
</v-btn>
</div>
<v-alert
type="error"
closable
v-model="show_alert_purchase"
density="compact"
variant="tonal"
class="mt-4"
>
Verifique los campos obligatorios.
</v-alert>
</v-form>
</v-container>
<CasherModal :total_purchase="calculateTotal" ref="casherModal" />
</template>
</v-form>
</v-container>
</template>
<script>
@@ -161,6 +330,7 @@
data() {
return {
api: inject('api'),
loading: true,
valid: false,
form_changed: false,
show_alert_lines: false,
@@ -179,54 +349,21 @@
required: value => !!value || 'Requerido.',
positive: value => value > 0 || 'La cantidad debe ser mayor que 0.',
},
menuItems: [
{ title: 'Inicio', route: '/'},
{ title: 'Compras', route:'/compras'},
],
clients: [],
products: [],
};
},
created() {
this.fetchClients();
this.fetchProducts();
this.fetchPaymentMethods();
},
watch: {
group () {
this.drawer = false
},
created() {
this.fetchClients();
this.fetchProducts();
this.fetchPaymentMethods();
},
beforeMount() {
window.addEventListener('beforeunload', this.confirmLeave);
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.confirmLeave);
},
computed: {
calculateTotal() {
return this.purchase.saleline_set.reduce((total, saleline) => {
return total + this.calculateSubtotal(saleline);
}, 0);
},
filteredClients() {
return this.clients.filter(client => {
if (this.client_search === '') {
return [];
} else {
return client.name.toLowerCase().includes(this.client_search.toLowerCase());
}
});
},
filteredProducts() {
return this.products.filter(product => {
if (this.product_search === '') {
return [];
} else {
return product.name.toLowerCase().includes(this.product_search.toLowerCase());
}
});
},
},
methods: {
openModal() {
@@ -250,7 +387,6 @@
const today = new Date();
const gmtOffSet = -5;
const localDate = new Date(today.getTime() + (gmtOffSet * 60 * 60 * 1000));
// Formatear la fecha y hora en el formato YYYY-MM-DDTHH:MM
const formattedDate = localDate.toISOString().slice(0,16);
return formattedDate;
},
@@ -267,6 +403,9 @@
})
.catch(error => {
console.error(error);
})
.finally(() => {
this.loading = false;
});
},
handleNewCustomer(newCustomer){
@@ -330,7 +469,7 @@
this.$router.push(route);
},
formatPrice(price) {
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'COP' }).format(price);
return new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', minimumFractionDigits: 0 }).format(price);
},
},
mounted() {
@@ -339,4 +478,28 @@
};
</script>
<style>
.page-header {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
}
.product-line:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.02);
}
.total-amount {
font-size: 2rem;
font-weight: 700;
color: rgb(46, 125, 50);
line-height: 1.2;
}
.submit-btn {
min-width: 220px;
}
@media (max-width: 599px) {
.total-amount {
font-size: 1.6rem;
}
}
</style>