add: agregar gestión de clientes con interfaz y funcionalidad de registro

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-12 14:49:08 -06:00
parent c6dadb22e8
commit 0ffa93019c
9 changed files with 399 additions and 8 deletions

View File

@ -0,0 +1,251 @@
<script setup>
import { ref, watch } from 'vue';
import { api, apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
saleData: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'save']);
/** Estado */
const form = ref({
name: '',
email: '',
phone: '',
address: '',
rfc: ''
});
const saving = ref(false);
/** Watchers */
// Resetear formulario cuando se abre el modal
watch(() => props.show, (isShown) => {
if (isShown) {
form.value = {
name: '',
email: '',
phone: '',
address: '',
rfc: ''
};
}
});
/** Métodos */
const handleSave = async () => {
// Validar que al menos el nombre esté presente
if (!form.value.name || form.value.name.trim() === '') {
window.Notify.error('El nombre del cliente es requerido');
return;
}
saving.value = true;
api.post(apiURL('clients'), {
data: form.value,
onSuccess: (response) => {
saving.value = false;
window.Notify.success('Cliente guardado correctamente');
emit('save', response.client);
handleClose();
},
onFail: (failData) => {
saving.value = false;
window.Notify.error(failData?.message || 'Error al guardar el cliente');
},
onError: (error) => {
saving.value = false;
window.Notify.error(error?.message || 'Error en la solicitud al guardar el cliente');
}
});
};
const handleClose = () => {
if (!saving.value) {
form.value = {
name: '',
email: '',
phone: '',
address: '',
rfc: ''
};
emit('close');
}
};
</script>
<template>
<Modal :show="show" max-width="lg" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 rounded-full bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="person_add" class="text-3xl text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Registrar Cliente
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
¿Deseas guardar los datos del cliente? (Opcional)
</p>
</div>
</div>
<button
@click="handleClose"
:disabled="saving"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Información de venta (opcional) -->
<div v-if="saleData" class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="receipt_long" class="text-lg text-gray-600 dark:text-gray-400" />
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Venta realizada</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Total: <span class="font-bold">${{ saleData.total?.toFixed(2) || '0.00' }}</span>
</p>
</div>
<!-- Formulario -->
<form @submit.prevent="handleSave" class="space-y-4">
<!-- Nombre (Requerido) -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Nombre Completo *
</label>
<div class="relative">
<input
v-model="form.name"
type="text"
placeholder="Nombre del cliente"
maxlength="255"
required
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="person" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Correo Electrónico
</label>
<div class="relative">
<input
v-model="form.email"
type="email"
placeholder="correo@ejemplo.com"
maxlength="255"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="email" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Teléfono -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Teléfono
</label>
<div class="relative">
<input
v-model="form.phone"
type="tel"
placeholder="(123) 456-7890"
maxlength="20"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="phone" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- RFC -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
RFC
</label>
<div class="relative">
<input
v-model="form.rfc"
type="text"
placeholder="XAXX010101000"
maxlength="13"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 uppercase"
:disabled="saving"
/>
<GoogleIcon name="badge" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Dirección -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Dirección
</label>
<div class="relative">
<textarea
v-model="form.address"
placeholder="Calle, número, colonia, ciudad"
maxlength="500"
rows="3"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 resize-none"
:disabled="saving"
></textarea>
<GoogleIcon name="location_on" class="absolute left-3 top-4 text-gray-400 text-xl" />
</div>
</div>
</form>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
:disabled="saving"
class="px-6 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancelar
</button>
<button
type="button"
@click="handleSave"
:disabled="saving || !form.name"
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-600 shadow-lg shadow-blue-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
>
<GoogleIcon
:name="saving ? 'hourglass_empty' : 'save'"
class="text-xl"
:class="{ 'animate-spin': saving }"
/>
<span v-if="saving">Guardando...</span>
<span v-else>Guardar Cliente</span>
</button>
</div>
</div>
</Modal>
</template>

View File

@ -458,6 +458,7 @@ export default {
cashRegister: 'Caja',
point: 'Punto de Venta',
sales: 'Ventas',
clients: 'Clientes'
},
cashRegister: {
title: 'Caja Registradora',
@ -560,4 +561,8 @@ export default {
noStock: 'No hay suficiente stock disponible',
total: 'Total a pagar'
},
clients: {
title: 'Clientes',
description: 'Gestión de clientes',
}
}

View File

@ -56,6 +56,11 @@ onMounted(() => {
name="pos.sales"
to="pos.sales.index"
/>
<Link
icon="accessibility"
name="pos.clients"
to="pos.clients.index"
/>
</Section>
<Section
v-if="hasPermission('users.index')"

View File

@ -1,10 +1,10 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { formatCurrency } from '@/utils/formatters';
import reportService from '@Services/reportService';
import useCashRegister from '@Stores/cashRegister';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { formatCurrency } from '@/utils/formatters';
import OpenModal from '@/pages/POS/CashRegister/OpenModal.vue';
// State

View File

@ -1,7 +1,6 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import Notify from '@Plugins/Notify';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';

View File

@ -0,0 +1,104 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Estado */
const clients = ref([]);
/** Métodos */
const searcher = useSearcher({
url: apiURL('clients'),
onSuccess: (r) => {
clients.value = r.clients;
},
onError: () => clients.value = []
});
/** Métodos auxiliares */
const copyClientInfo = (client) => {
const info = `
Nombre: ${client.name}
Email: ${client.email || 'N/A'}
Teléfono: ${client.phone || 'N/A'}
Dirección: ${client.address || 'N/A'}
RFC: ${client.rfc || 'N/A'}
`.trim();
navigator.clipboard.writeText(info).then(() => {
window.Notify.success(`Información de ${client.name} copiada al portapapeles`);
}).catch(() => {
window.Notify.error('No se pudo copiar la información');
});
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('clients.title')"
placeholder="Buscar por nombre..."
@search="(x) => searcher.search(x)"
>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="clients"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CORREO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TELEFONO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DIRECCIÓN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
</template>
<template #body="{items}">
<tr
v-for="client in items"
:key="client.id"
@click="copyClientInfo(client)"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ client.name }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.email }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.phone }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.address }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.rfc }}</p>
</td>
</tr>
</template>
<template #empty>
<td colspan="5" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="person"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

@ -3,6 +3,7 @@ import { onMounted, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSearcher, apiURL } from '@Services/Api';
import { page } from '@Services/Page';
import { formatCurrency } from '@/utils/formatters';
import useCart from '@Stores/cart';
import salesService from '@Services/salesService';
import ticketService from '@Services/ticketService';
@ -11,6 +12,7 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
import ProductCard from '@Components/POS/ProductCard.vue';
import CartItem from '@Components/POS/CartItem.vue';
import CheckoutModal from '@Components/POS/CheckoutModal.vue';
import ClientModal from '@Components/POS/ClientModal.vue';
import QRscan from '@Components/POS/QRscan.vue';
/** i18n */
@ -25,6 +27,8 @@ const showCheckoutModal = ref(false);
const searchQuery = ref('');
const processingPayment = ref(false);
const scanMode = ref(false);
const showClientModal = ref(false);
const lastSaleData = ref(null);
/** Buscador de productos */
const searcher = useSearcher({
@ -193,6 +197,12 @@ const handleConfirmSale = async (paymentData) => {
}
}
lastSaleData.value = {
id: response.id,
total: saleData.total,
payment_method: saleData.payment_method
};
// Limpiar carrito
cart.clear();
@ -202,6 +212,8 @@ const handleConfirmSale = async (paymentData) => {
// Recargar productos para actualizar stock
searcher.search();
showClientModal.value = true;
} catch (error) {
console.error('Error en venta:', error);
@ -217,6 +229,11 @@ const handleConfirmSale = async (paymentData) => {
}
};
const closeClientModal = () => {
showClientModal.value = false;
lastSaleData.value = null;
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
@ -361,11 +378,11 @@ onMounted(() => {
<div class="space-y-2">
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{{ $t('cart.subtotal') }}</span>
<span class="font-semibold">${{ cart.subtotal.toFixed(2) }}</span>
<span class="font-semibold">${{ formatCurrency(cart.subtotal) }}</span>
</div>
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>IVA (16%)</span>
<span class="font-semibold">${{ cart.tax.toFixed(2) }}</span>
<span class="font-semibold">${{ formatCurrency(cart.tax) }}</span>
</div>
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
@ -373,7 +390,7 @@ onMounted(() => {
{{ $t('cart.total') }}
</span>
<span class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
${{ cart.total.toFixed(2) }}
{{ formatCurrency(cart.total) }}
</span>
</div>
</div>
@ -413,5 +430,11 @@ onMounted(() => {
@close="closeCheckout"
@confirm="handleConfirmSale"
/>
<ClientModal
:show="showClientModal"
:sale-data="lastSaleData"
@close="closeClientModal"
@save="handleClientSave"
/>
</div>
</template>

View File

@ -70,6 +70,11 @@ const router = createRouter({
path: 'sales',
name: 'pos.sales.index',
component: () => import('@Pages/POS/Sales/Index.vue')
},
{
path: 'clients',
name: 'pos.clients.index',
component: () => import('@Pages/POS/Clients/Index.vue')
}
]
},

View File

@ -1,5 +1,4 @@
import { defineStore } from 'pinia';
import Notify from '@Plugins/Notify';
const useCart = defineStore('cart', {
state: () => ({
@ -48,7 +47,7 @@ const useCart = defineStore('cart', {
if (existingItem.quantity < product.stock) {
existingItem.quantity++;
} else {
Notify.warning('No hay suficiente stock disponible');
window.Notify.warning('No hay suficiente stock disponible');
}
} else {
// Agregar nuevo item
@ -73,7 +72,7 @@ const useCart = defineStore('cart', {
} else if (quantity <= item.max_stock) {
item.quantity = quantity;
} else {
Notify.warning('No hay suficiente stock disponible');
window.Notify.warning('No hay suficiente stock disponible');
}
}
},