add: agregar gestión de clientes con interfaz y funcionalidad de registro
This commit is contained in:
parent
c6dadb22e8
commit
0ffa93019c
251
src/components/POS/ClientModal.vue
Normal file
251
src/components/POS/ClientModal.vue
Normal 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>
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
@ -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')"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
104
src/pages/POS/Clients/Index.vue
Normal file
104
src/pages/POS/Clients/Index.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user