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> <template>
<v-container> <v-container fluid class="pa-4 pa-md-6">
<v-form ref="purchase" v-model="valid" @change="onFormChange"> <v-form ref="purchase" v-model="valid" @change="onFormChange">
<v-row>
<v-col> <!-- Encabezado -->
<v-autocomplete <v-sheet class="page-header d-flex align-center pa-4 pa-md-6 mb-4 rounded-lg">
v-model="purchase.customer" <v-icon start size="40" color="white" class="mr-3">mdi-cart-plus</v-icon>
:items="filteredClients" <div>
:search="client_search" <h1 class="text-h5 text-md-h4 font-weight-bold text-white mb-0">Nueva Compra</h1>
no-data-text="No se hallaron clientes" <p class="text-body-2 text-white text-medium-emphasis mb-0">Registra una nueva venta en el sistema</p>
item-title="name" </div>
item-value="id" </v-sheet>
@update:model-value="onFormChange"
label="Cliente" <!-- Loading -->
:rules="[rules.required]" <template v-if="loading">
required <v-skeleton-loader
class="mr-4" class="mb-4 rounded-lg"
></v-autocomplete> type="card-heading, list-item-two-line, list-item-two-line"
<!--<v-btn color="primary" @click="openModal">Agregar Cliente</v-btn>--> ></v-skeleton-loader>
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer"/> </template>
</v-col>
<v-col lg="4"> <template v-else>
<v-text-field <!-- Card: Información del Cliente -->
v-model="purchase.date" <v-card class="mb-4 rounded-lg" elevation="2">
label="Fecha" <v-card-item>
type="datetime-local" <template #prepend>
:rules="[rules.required]" <v-icon color="primary" size="36">mdi-account</v-icon>
required </template>
readonly <v-card-title class="font-weight-bold text-h6">Información del Cliente</v-card-title>
></v-text-field> <v-card-subtitle>Datos básicos de la compra</v-card-subtitle>
</v-col> </v-card-item>
</v-row> <v-divider></v-divider>
<v-textarea <v-card-text>
v-model="purchase.notes" <v-row>
label="Notas" <v-col cols="12" md="6">
rows="2" <v-autocomplete
></v-textarea> v-model="purchase.customer"
<v-divider></v-divider> :items="clients"
<v-container> :search="client_search"
<v-toolbar> no-data-text="No se hallaron clientes"
<v-toolbar-title secondary>Productos</v-toolbar-title> item-title="name"
</v-toolbar> item-value="id"
<v-container v-for="(line, index) in purchase.saleline_set" :key="line.id"> @update:model-value="onFormChange"
<v-row> label="Cliente"
<v-col :rules="[rules.required]"
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]"
required required
></v-text-field> clearable
</v-col> variant="outlined"
</v-row> density="comfortable"
<v-row> hide-details="auto"
<v-col> ></v-autocomplete>
<v-text-field <CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer" />
v-model.number="line.unit_price" </v-col>
label="Precio" <v-col cols="12" md="3">
type="number" <v-text-field
:rules="[rules.required]" v-model="purchase.date"
prefix="$" label="Fecha"
required type="datetime-local"
readonly :rules="[rules.required]"
></v-text-field> required
</v-col> readonly
<v-col> variant="outlined"
<v-text-field density="comfortable"
v-model="line.measuring_unit" hide-details="auto"
label="UdM" ></v-text-field>
persistent-placeholder="true" </v-col>
readonly <v-col cols="12" md="3">
></v-text-field> <v-btn
</v-col> variant="tonal"
<v-col> color="primary"
<v-text-field size="small"
type="number" class="mt-1"
:value="calculateSubtotal(line)" prepend-icon="mdi-plus"
label="Subtotal" @click="openModal"
prefix="$" >
readonly Nuevo Cliente
disable </v-btn>
persistent-placeholder="true" </v-col>
></v-text-field> </v-row>
</v-col> <v-row class="mt-0">
<v-col> <v-col cols="12">
<v-btn @click="removeLine(index)" color="red">Eliminar</v-btn> <v-textarea
</v-col> v-model="purchase.notes"
</v-row> label="Notas"
<v-alert type="warning" :duration="2000" closable v-model="show_alert_lines"> rows="2"
No se puede eliminar la única línea. variant="outlined"
</v-alert> density="comfortable"
</v-container> hide-details="auto"
<v-btn @click="addLine" color="blue">Agregar</v-btn> ></v-textarea>
</v-container> </v-col>
<v-divider></v-divider> </v-row>
<v-text-field </v-card-text>
:value="calculateTotal" </v-card>
label="Total"
prefix="$" <!-- Card: Productos -->
readonly <v-card class="mb-4 rounded-lg" elevation="2">
persistent-placeholder="true" <v-card-item>
></v-text-field> <template #prepend>
<v-container v-if="calculateTotal > 0"> <v-icon color="green-darken-1" size="36">mdi-package-variant-closed</v-icon>
<v-select </template>
:items="payment_methods" <v-card-title class="font-weight-bold text-h6">Productos</v-card-title>
v-model="purchase.payment_method" <v-card-subtitle>Agrega los productos de la compra</v-card-subtitle>
item-title="text" </v-card-item>
item-value="value" <v-divider></v-divider>
label="Pago en"
:rules="[rules.required]" <!-- Encabezados de columnas (solo desktop) -->
required <div class="d-none d-md-flex px-6 pt-3 text-caption font-weight-bold text-grey">
></v-select> <div class="flex-grow-1 flex-shrink-1" style="min-width:0">Producto</div>
<v-btn @click="openCasherModal" v-if="purchase.payment_method === 'CASH'">Calcular Devuelta</v-btn> <div class="mx-2 text-center" style="width:100px; flex-shrink:0">Cantidad</div>
<CasherModal :total_purchase="calculateTotal" ref="casherModal"</CasherModal> <div class="mx-2 text-end" style="width:110px; flex-shrink:0">Precio</div>
</v-container> <div class="mx-2 text-end" style="width:110px; flex-shrink:0">Subtotal</div>
<v-btn @click="submit" color="green">Comprar</v-btn> <div style="width:40px; flex-shrink:0"></div>
<v-alert type="error" :duration="2000" closable v-model="show_alert_purchase"> </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. Verifique los campos obligatorios.
</v-alert> </v-alert>
</v-form>
</v-container> <CasherModal :total_purchase="calculateTotal" ref="casherModal" />
</template>
</v-form>
</v-container>
</template> </template>
<script> <script>
@@ -161,6 +330,7 @@
data() { data() {
return { return {
api: inject('api'), api: inject('api'),
loading: true,
valid: false, valid: false,
form_changed: false, form_changed: false,
show_alert_lines: false, show_alert_lines: false,
@@ -179,54 +349,21 @@
required: value => !!value || 'Requerido.', required: value => !!value || 'Requerido.',
positive: value => value > 0 || 'La cantidad debe ser mayor que 0.', positive: value => value > 0 || 'La cantidad debe ser mayor que 0.',
}, },
menuItems: [
{ title: 'Inicio', route: '/'},
{ title: 'Compras', route:'/compras'},
],
clients: [], clients: [],
products: [], products: [],
}; };
}, },
created() { created() {
this.fetchClients(); this.fetchClients();
this.fetchProducts(); this.fetchProducts();
this.fetchPaymentMethods(); this.fetchPaymentMethods();
},
watch: {
group () {
this.drawer = false
},
}, },
beforeMount() {
window.addEventListener('beforeunload', this.confirmLeave);
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.confirmLeave);
},
computed: { computed: {
calculateTotal() { calculateTotal() {
return this.purchase.saleline_set.reduce((total, saleline) => { return this.purchase.saleline_set.reduce((total, saleline) => {
return total + this.calculateSubtotal(saleline); return total + this.calculateSubtotal(saleline);
}, 0); }, 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: { methods: {
openModal() { openModal() {
@@ -250,7 +387,6 @@
const today = new Date(); const today = new Date();
const gmtOffSet = -5; const gmtOffSet = -5;
const localDate = new Date(today.getTime() + (gmtOffSet * 60 * 60 * 1000)); 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); const formattedDate = localDate.toISOString().slice(0,16);
return formattedDate; return formattedDate;
}, },
@@ -267,6 +403,9 @@
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
})
.finally(() => {
this.loading = false;
}); });
}, },
handleNewCustomer(newCustomer){ handleNewCustomer(newCustomer){
@@ -330,7 +469,7 @@
this.$router.push(route); this.$router.push(route);
}, },
formatPrice(price) { 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() { mounted() {
@@ -339,4 +478,28 @@
}; };
</script> </script>
<style> <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> </style>