feat: add inline catalog checkout with modals

Replace redirect to /comprar with a 2-step modal flow (cart confirmation
+ personal data) on the catalog page. Add createCatalogPurchase API
endpoint for catalog sales.
This commit is contained in:
2026-05-28 17:13:22 -05:00
parent 2b52c63133
commit e816ae3e7d
3 changed files with 229 additions and 51 deletions

View File

@@ -87,6 +87,98 @@
</div>
</v-col>
</v-row>
<!-- Modal 1: Confirmación de productos -->
<v-dialog v-model="checkoutDialog" max-width="600" persistent>
<v-card>
<v-card-title class="headline">Confirmar Compra</v-card-title>
<v-card-text>
<v-list v-if="cartItems.length > 0" class="product-list-scroll">
<v-list-item v-for="item in cartItems" :key="item.id">
<div class="d-flex justify-space-between align-center">
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-grey">
{{ currency(item.price) }} x {{ item.quantity }}
</div>
</div>
<div class="font-weight-bold text-success">
{{ currency(item.price * item.quantity) }}
</div>
</div>
</v-list-item>
</v-list>
<v-divider class="my-3"></v-divider>
<div class="d-flex justify-space-between text-h6">
<span class="font-weight-bold">Total</span>
<span class="font-weight-bold text-success">{{
currency(cartStore.cartTotal)
}}</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="checkoutDialog = false">Cancelar</v-btn>
<v-btn color="primary" variant="elevated" @click="onConfirmCheckout"
>Confirmar</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Modal 2: Datos personales -->
<v-dialog v-model="personalDataDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Datos de Contacto</v-card-title>
<v-card-text>
<v-form ref="personalForm">
<v-text-field
v-model="customerName"
label="Nombre completo"
:rules="[rules.required]"
required
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerAddress"
label="Dirección"
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerPhone"
label="Teléfono"
variant="outlined"
class="mb-3"
></v-text-field>
<v-select
v-model="pickupMethod"
:items="pickupOptions"
item-title="text"
item-value="value"
label="Recogida"
:rules="[rules.required]"
required
variant="outlined"
></v-select>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelPurchase">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="onSubmitPurchase"
:loading="isSubmitting"
:disabled="isSubmitting"
>
Finalizar Compra
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
@@ -137,6 +229,20 @@ export default {
currentPage: 1,
itemsPerPage: 20,
itemsPerPageOptions: [10, 20, 50, 100],
checkoutDialog: false,
personalDataDialog: false,
customerName: "",
customerAddress: "",
customerPhone: "",
pickupMethod: "STORE",
pickupOptions: [
{ text: "En Sitio", value: "STORE" },
{ text: "Domicilio", value: "DELIVERY" },
],
isSubmitting: false,
rules: {
required: (value) => !!value || "Requerido.",
},
};
},
computed: {
@@ -178,11 +284,11 @@ export default {
if (this.totalPages <= 7) {
return this.totalPages;
}
// Breakpoints responsivos basados en windowWidth
// OPTIMIZADO: Reducidos para evitar que desaparezcan los iconos de navegación
const width = this.windowWidth;
if (width < 400) {
return 3; // Extra small mobile
} else if (width < 680) {
@@ -255,7 +361,65 @@ export default {
}
},
goToCheckout() {
this.$router.push("/comprar");
this.checkoutDialog = true;
},
onConfirmCheckout() {
this.checkoutDialog = false;
this.personalDataDialog = true;
},
cancelPurchase() {
this.checkoutDialog = false;
this.personalDataDialog = false;
this.customerName = "";
this.customerAddress = "";
this.customerPhone = "";
this.pickupMethod = "STORE";
},
async onSubmitPurchase() {
const form = this.$refs.personalForm;
if (form) {
const { valid } = await form.validate();
if (!valid) return;
}
this.isSubmitting = true;
const payload = {
date: this.getCurrentDate(),
customer: 1,
notes: "",
payment_method: "CASH",
saleline_set: this.cartItems.map((item) => ({
product: item.id,
unit_price: item.price,
quantity: item.quantity,
measuring_unit: item.measuring_unit || "Unidad",
})),
customer_name: this.customerName,
customer_address: this.customerAddress,
customer_phone: this.customerPhone,
pickup_method: this.pickupMethod,
};
this.api
.createCatalogPurchase(payload)
.then((data) => {
this.cartStore.clearCart();
this.personalDataDialog = false;
this.$router.push({
path: "/summary_purchase",
query: { id: parseInt(data.id) },
});
})
.catch((error) => {
console.error("Error al crear la compra:", error);
this.isSubmitting = false;
});
},
getCurrentDate() {
const today = new Date();
const gmtOffSet = -5;
const localDate = new Date(today.getTime() + gmtOffSet * 60 * 60 * 1000);
return localDate.toISOString().slice(0, 16);
},
toggleCart() {
this.cartCollapsed = !this.cartCollapsed;
@@ -396,4 +560,9 @@ export default {
margin-bottom: 14px;
}
}
.product-list-scroll {
max-height: 400px;
overflow-y: auto;
}
</style>

View File

@@ -1,67 +1,71 @@
class Api {
constructor (apiImplementation) {
this.apiImplementation = apiImplementation;
}
constructor(apiImplementation) {
this.apiImplementation = apiImplementation;
}
getCustomers() {
return this.apiImplementation.getCustomers();
}
getCustomers() {
return this.apiImplementation.getCustomers();
}
getProducts() {
return this.apiImplementation.getProducts();
}
getProducts() {
return this.apiImplementation.getProducts();
}
getPaymentMethods() {
return this.apiImplementation.getPaymentMethods();
}
getPaymentMethods() {
return this.apiImplementation.getPaymentMethods();
}
getSummaryPurchase(purchaseId) {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getSummaryPurchase(purchaseId) {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
getListReconcliations(page=1, itemsPerPage=10) {
return this.apiImplementation.getListReconcliations(page, itemsPerPage);
}
getListReconcliations(page = 1, itemsPerPage = 10) {
return this.apiImplementation.getListReconcliations(page, itemsPerPage);
}
getReconciliation(reconciliationId) {
return this.apiImplementation.getReconciliation(reconciliationId);
}
getReconciliation(reconciliationId) {
return this.apiImplementation.getReconciliation(reconciliationId);
}
createPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase);
}
createPurchase(purchase) {
return this.apiImplementation.createCatalogPurchase(purchase);
}
createReconciliationJar(reconciliation) {
return this.apiImplementation.createReconciliationJar(reconciliation);
}
createCatalogPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase);
}
createCustomer(customer) {
return this.apiImplementation.createCustomer(customer);
}
createReconciliationJar(reconciliation) {
return this.apiImplementation.createReconciliationJar(reconciliation);
}
getCSVForTryton() {
return this.apiImplementation.getCSVForTryton();
}
createCustomer(customer) {
return this.apiImplementation.createCustomer(customer);
}
getProductsFromTryton() {
return this.apiImplementation.getProductsFromTryton();
}
getCSVForTryton() {
return this.apiImplementation.getCSVForTryton();
}
getCustomersFromTryton() {
return this.apiImplementation.getCustomersFromTryton();
}
getProductsFromTryton() {
return this.apiImplementation.getProductsFromTryton();
}
sendSalesToTryton(){
return this.apiImplementation.sendSalesToTryton();
}
getCustomersFromTryton() {
return this.apiImplementation.getCustomersFromTryton();
}
getCurrentUser() {
return this.apiImplementation.getCurrentUser();
}
sendSalesToTryton() {
return this.apiImplementation.sendSalesToTryton();
}
getCurrentUser() {
return this.apiImplementation.getCurrentUser();
}
}
export default Api;

View File

@@ -60,6 +60,11 @@ class DjangoApi {
return this.postRequest(url, purchase);
}
createCatalogPurchase(purchase) {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.postRequest(url, purchase);
}
createReconciliationJar(reconciliation) {
const url = this.base + "/don_confiao/reconciliate_jar";
return this.postRequest(url, reconciliation);