FEAT:Modulo de facturación
This commit is contained in:
parent
2b7b884794
commit
f53d0ff457
@ -4,12 +4,20 @@ import { useRouter } from 'vue-router';
|
|||||||
import useLoader from '@Stores/Loader';
|
import useLoader from '@Stores/Loader';
|
||||||
import { hasToken } from '@Services/Api';
|
import { hasToken } from '@Services/Api';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/factura/'];
|
||||||
|
|
||||||
/** Definidores */
|
/** Definidores */
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loader = useLoader();
|
const loader = useLoader();
|
||||||
|
|
||||||
/** Ciclos */
|
/** Ciclos */
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
|
const currentPath = router.currentRoute.value.path;
|
||||||
|
|
||||||
|
if (PUBLIC_PATHS.some(prefix => currentPath.startsWith(prefix))) return;
|
||||||
|
|
||||||
if(!hasToken()) {
|
if(!hasToken()) {
|
||||||
return router.push({ name: 'auth.index' })
|
return router.push({ name: 'auth.index' })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,11 @@ onMounted(() => {
|
|||||||
name="Entrega de caja"
|
name="Entrega de caja"
|
||||||
to="checkout.index"
|
to="checkout.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
icon="request_quote"
|
||||||
|
name="Solicitudes de Factura"
|
||||||
|
to="invoice-request.index"
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section
|
<Section
|
||||||
v-if="hasPermission('users.index')"
|
v-if="hasPermission('users.index')"
|
||||||
|
|||||||
206
src/pages/App/InvoiceRequest/Index.vue
Normal file
206
src/pages/App/InvoiceRequest/Index.vue
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useSearcher, useApi } from '@Services/Api';
|
||||||
|
import { apiTo } from './Module';
|
||||||
|
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import IconButton from '@Holos/Button/Icon.vue';
|
||||||
|
import PageHeader from '@Holos/PageHeader.vue';
|
||||||
|
import ShowModal from './Modal/Show.vue';
|
||||||
|
import ModalController from '@Controllers/ModalController.js';
|
||||||
|
|
||||||
|
/** Controladores */
|
||||||
|
const Modal = new ModalController();
|
||||||
|
const api = useApi();
|
||||||
|
const showModal = ref(Modal.showModal);
|
||||||
|
const modelModal = ref(Modal.modelModal);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref({ data: [], total: 0, current_page: 1, last_page: 1 });
|
||||||
|
const statusFilter = ref('');
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||||
|
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||||
|
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800 border-green-200' },
|
||||||
|
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800 border-red-200' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTabs = [
|
||||||
|
{ value: '', label: 'Todas' },
|
||||||
|
{ value: 'pending', label: 'Pendientes' },
|
||||||
|
{ value: 'processing', label: 'En proceso' },
|
||||||
|
{ value: 'completed', label: 'Completadas'},
|
||||||
|
{ value: 'rejected', label: 'Rechazadas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Buscador */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiTo(),
|
||||||
|
filters: {},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
models.value = data.models;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const doSearch = (page = 1) => {
|
||||||
|
const filters = { page };
|
||||||
|
if (statusFilter.value) filters.status = statusFilter.value;
|
||||||
|
searcher.search('', filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => doSearch());
|
||||||
|
|
||||||
|
watch(statusFilter, () => doSearch(1));
|
||||||
|
|
||||||
|
/** Paginación */
|
||||||
|
const pages = () => {
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 1; i <= models.value.last_page; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Abrir detalle — carga info completa */
|
||||||
|
const openDetail = (model) => {
|
||||||
|
Modal.switchShowModal(model);
|
||||||
|
api.get(apiTo(model.id), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Modal.modelModal.value = data.model;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Actualiza fila en la lista al recibir cambio del modal */
|
||||||
|
const handleUpdated = (updatedModel) => {
|
||||||
|
const idx = models.value.data.findIndex(m => m.id === updatedModel.id);
|
||||||
|
if (idx > -1) {
|
||||||
|
models.value.data[idx] = updatedModel;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount) => {
|
||||||
|
if (!amount) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Solicitudes de Factura" />
|
||||||
|
|
||||||
|
<div class="mx-4 mb-4 space-y-4">
|
||||||
|
|
||||||
|
<!-- Filtros de estado -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tab in statusTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="px-4 py-1.5 rounded-full text-sm font-medium border transition-colors"
|
||||||
|
:class="statusFilter === tab.value
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-white text-gray-600 border-gray-300 hover:border-primary hover:text-primary'"
|
||||||
|
@click="statusFilter = tab.value"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">#</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Ciudadano</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">RFC</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Monto pagado</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Estado</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Fecha solicitud</th>
|
||||||
|
<th class="px-4 py-3 text-center text-sm font-semibold w-20">Acción</th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr
|
||||||
|
v-for="model in items"
|
||||||
|
:key="model.id"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
@click="openDetail(model)"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 font-mono">{{ model.id }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
|
||||||
|
{{ model.name || model.payment?.model?.name || '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 font-mono">{{ model.rfc }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{{ formatAmount(model.payment?.total_amount) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
|
||||||
|
:class="statusConfig[model.status]?.cls ?? 'bg-gray-100 text-gray-700'"
|
||||||
|
>
|
||||||
|
{{ statusConfig[model.status]?.label ?? model.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ formatDate(model.request_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 flex justify-center" @click.stop>
|
||||||
|
<IconButton
|
||||||
|
icon="visibility"
|
||||||
|
title="Ver detalle"
|
||||||
|
outline
|
||||||
|
@click="openDetail(model)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="7" class="px-6 py-10 text-center text-gray-400 text-sm">
|
||||||
|
No hay solicitudes de factura con los filtros seleccionados.
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación personalizada -->
|
||||||
|
<div
|
||||||
|
v-if="models.last_page > 1"
|
||||||
|
class="flex justify-end flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="p in pages()"
|
||||||
|
:key="p"
|
||||||
|
class="px-3 py-1 text-sm border rounded transition-colors"
|
||||||
|
:class="models.current_page === p
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-white text-gray-600 border-gray-300 hover:border-primary'"
|
||||||
|
@click="doSearch(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de detalle -->
|
||||||
|
<ShowModal
|
||||||
|
:show="showModal"
|
||||||
|
:model="modelModal"
|
||||||
|
:loading="api.processing"
|
||||||
|
@close="Modal.switchShowModal()"
|
||||||
|
@updated="handleUpdated"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
356
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal file
356
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useForm, useApi } from '@Services/Api';
|
||||||
|
import { apiTo } from '../Module';
|
||||||
|
|
||||||
|
import DialogModal from '@Holos/DialogModal.vue';
|
||||||
|
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||||
|
import SecondaryButton from '@Holos/Button/Secondary.vue';
|
||||||
|
import SingleFile from '@Holos/Form/SingleFile.vue';
|
||||||
|
import Input from '@Holos/Form/Input.vue';
|
||||||
|
import Textarea from '@Holos/Form/Textarea.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
model: Object,
|
||||||
|
loading: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
|
/** API para cambio de estado */
|
||||||
|
const apiStatus = useApi();
|
||||||
|
|
||||||
|
/** Formulario de subida CFDI */
|
||||||
|
const invoiceForm = ref(useForm({
|
||||||
|
xml_file: null,
|
||||||
|
pdf_file: null,
|
||||||
|
folio: '',
|
||||||
|
uuid: '',
|
||||||
|
stamped_at: '',
|
||||||
|
notes: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Resetear formulario cuando se abre el modal */
|
||||||
|
watch(() => props.show, (open) => {
|
||||||
|
if (open) {
|
||||||
|
invoiceForm.value = useForm({
|
||||||
|
xml_file: null,
|
||||||
|
pdf_file: null,
|
||||||
|
folio: '',
|
||||||
|
uuid: '',
|
||||||
|
stamped_at: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800' },
|
||||||
|
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800' },
|
||||||
|
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const canChangeStatus = computed(() =>
|
||||||
|
props.model?.status === 'pending' || props.model?.status === 'processing'
|
||||||
|
);
|
||||||
|
|
||||||
|
const canUpload = computed(() =>
|
||||||
|
props.model?.status !== 'completed' && props.model?.status !== 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount) => {
|
||||||
|
if (!amount) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cambiar estado */
|
||||||
|
const changeStatus = (status) => {
|
||||||
|
apiStatus.put(apiTo(props.model.id, 'status'), {
|
||||||
|
data: { status },
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Notify.success(
|
||||||
|
status === 'processing'
|
||||||
|
? 'Solicitud marcada como "En proceso".'
|
||||||
|
: 'Solicitud rechazada.'
|
||||||
|
);
|
||||||
|
emit('updated', data.model);
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
Notify.warning(data?.message ?? 'No se pudo cambiar el estado.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subir CFDI */
|
||||||
|
const uploadInvoice = () => {
|
||||||
|
if (!invoiceForm.value.xml_file && !invoiceForm.value.pdf_file) {
|
||||||
|
Notify.warning('Debes adjuntar al menos el archivo XML o el PDF del CFDI.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
invoiceForm.value.post(apiTo(props.model.id, 'invoice'), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Notify.success(data?.message ?? 'Factura subida y enviada al ciudadano.');
|
||||||
|
emit('updated', data.model);
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al subir los archivos. Verifica el formulario.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal
|
||||||
|
:show="show"
|
||||||
|
max-width="2xl"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-3 py-1">
|
||||||
|
<span class="font-bold text-xl">
|
||||||
|
Solicitud de Factura #{{ model?.id }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="model?.status"
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="statusConfig[model.status]?.cls"
|
||||||
|
>
|
||||||
|
{{ statusConfig[model.status]?.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- Cargando -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="model?.id" class="px-4 pt-2 pb-4 space-y-5">
|
||||||
|
|
||||||
|
<!-- ── Datos del ciudadano ── -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
|
||||||
|
Datos del ciudadano
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">RFC:</span> <span class="font-mono font-medium">{{ model.rfc }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Nombre:</span> {{ model.name || '—' }}</div>
|
||||||
|
<div><span class="text-gray-500">Email:</span> {{ model.email }}</div>
|
||||||
|
<div><span class="text-gray-500">Teléfono:</span> {{ model.phone || '—' }}</div>
|
||||||
|
<div><span class="text-gray-500">Razón social:</span> {{ model.business_name || '—' }}</div>
|
||||||
|
<div><span class="text-gray-500">C.P. fiscal:</span> {{ model.fiscal_postal_code }}</div>
|
||||||
|
<div class="col-span-2"><span class="text-gray-500">Dirección:</span> {{ model.address || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Datos fiscales ── -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
|
||||||
|
Datos fiscales
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Régimen fiscal:</span>
|
||||||
|
<span v-if="model.fiscal_regime" class="ml-1">
|
||||||
|
<span class="font-mono font-medium">{{ model.fiscal_regime.code }}</span>
|
||||||
|
— {{ model.fiscal_regime.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else> —</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Uso del CFDI:</span>
|
||||||
|
<span v-if="model.cfdi_use" class="ml-1">
|
||||||
|
<span class="font-mono font-medium">{{ model.cfdi_use.code }}</span>
|
||||||
|
— {{ model.cfdi_use.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else> —</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Pago relacionado ── -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
|
||||||
|
Pago relacionado
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Monto:</span> <span class="font-medium text-green-700">{{ formatAmount(model.payment?.total_amount) }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(model.payment?.paid_at) }}</div>
|
||||||
|
<div><span class="text-gray-500">Infractor:</span> {{ model.payment?.model?.name || '—' }}</div>
|
||||||
|
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ model.payment?.model?.plate || '—' }}</span></div>
|
||||||
|
<div><span class="text-gray-500">CURP:</span> <span class="font-mono text-xs">{{ model.payment?.model?.curp || '—' }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conceptos de cargo -->
|
||||||
|
<div v-if="model.payment?.model?.charge_concepts?.length" class="mt-3">
|
||||||
|
<p class="text-xs font-semibold text-gray-500 mb-1">Conceptos de cargo:</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="cc in model.payment.model.charge_concepts"
|
||||||
|
:key="cc.id"
|
||||||
|
class="flex justify-between items-center bg-gray-50 rounded px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{{ cc.name }}</span>
|
||||||
|
<span class="text-gray-500 ml-1">({{ cc.article }})</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-700">
|
||||||
|
{{ formatAmount(cc.pivot?.override_amount) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Factura ya generada ── -->
|
||||||
|
<section v-if="model.invoice">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
|
||||||
|
Factura generada
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Folio:</span> <span class="font-mono font-medium">{{ model.invoice.folio || '—' }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Timbrada el:</span> {{ formatDate(model.invoice.stamped_at) }}</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="text-gray-500">UUID SAT:</span>
|
||||||
|
<span class="font-mono text-xs ml-1">{{ model.invoice.uuid || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="model.invoice.notes" class="col-span-2">
|
||||||
|
<span class="text-gray-500">Notas:</span> {{ model.invoice.notes }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-3">
|
||||||
|
<a
|
||||||
|
v-if="model.invoice.xml_url"
|
||||||
|
:href="model.invoice.xml_url"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-base"
|
||||||
|
name="code"
|
||||||
|
/>
|
||||||
|
Descargar XML
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="model.invoice.pdf_url"
|
||||||
|
:href="model.invoice.pdf_url"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-base"
|
||||||
|
name="picture_as_pdf"
|
||||||
|
/>
|
||||||
|
Descargar PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Cambiar estado ── -->
|
||||||
|
<section v-if="canChangeStatus">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
|
||||||
|
Cambiar estado
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-if="model.status === 'pending'"
|
||||||
|
class="inline-flex items-center gap-1 px-4 py-1.5 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
:disabled="apiStatus.processing"
|
||||||
|
@click="changeStatus('processing')"
|
||||||
|
>
|
||||||
|
Marcar en proceso
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 px-4 py-1.5 rounded text-sm font-medium bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||||
|
:disabled="apiStatus.processing"
|
||||||
|
@click="changeStatus('rejected')"
|
||||||
|
>
|
||||||
|
Rechazar solicitud
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Subir CFDI ── -->
|
||||||
|
<section v-if="canUpload">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 border-b pb-1">
|
||||||
|
Subir CFDI (XML / PDF)
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<SingleFile
|
||||||
|
v-model="invoiceForm.xml_file"
|
||||||
|
title="Archivo XML"
|
||||||
|
accept=".xml,text/xml,application/xml"
|
||||||
|
/>
|
||||||
|
<SingleFile
|
||||||
|
v-model="invoiceForm.pdf_file"
|
||||||
|
title="Archivo PDF"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.folio"
|
||||||
|
id="Folio"
|
||||||
|
placeholder="Ej. A-0001"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.uuid"
|
||||||
|
id="UUID del timbre"
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.stamped_at"
|
||||||
|
id="Fecha de timbrado"
|
||||||
|
type="datetime-local"
|
||||||
|
/>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="invoiceForm.notes"
|
||||||
|
id="Notas internas"
|
||||||
|
placeholder="Ej. Generado con PAC Facturama"
|
||||||
|
/>
|
||||||
|
<div class="pt-1">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2 rounded text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
|
:disabled="invoiceForm.processing"
|
||||||
|
@click="uploadInvoice"
|
||||||
|
>
|
||||||
|
<span v-if="invoiceForm.processing">Subiendo…</span>
|
||||||
|
<span v-else>Subir y enviar al ciudadano</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="emit('close')">
|
||||||
|
Cerrar
|
||||||
|
</SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
10
src/pages/App/InvoiceRequest/Module.js
Normal file
10
src/pages/App/InvoiceRequest/Module.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
const apiTo = (id = null, action = '') => {
|
||||||
|
let path = 'invoice-requests';
|
||||||
|
if (id !== null) path += `/${id}`;
|
||||||
|
if (action) path += `/${action}`;
|
||||||
|
return apiURL(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { apiTo };
|
||||||
499
src/pages/Public/Invoice/Index.vue
Normal file
499
src/pages/Public/Invoice/Index.vue
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
|
||||||
|
/** Ruta actual (para leer :qr_token) */
|
||||||
|
const route = useRoute();
|
||||||
|
const qrToken = route.params.qr_token;
|
||||||
|
|
||||||
|
/** Estado general */
|
||||||
|
const loading = ref(true);
|
||||||
|
const notFound = ref(false);
|
||||||
|
const paymentData = ref(null);
|
||||||
|
const invoiceRequest = ref(null);
|
||||||
|
const hasInvoiceReq = ref(false);
|
||||||
|
const submitted = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const serverErrors = ref({});
|
||||||
|
|
||||||
|
/** Catálogos SAT */
|
||||||
|
const fiscalRegimes = ref([]);
|
||||||
|
const cfdiUses = ref([]);
|
||||||
|
const loadingCfdi = ref(false);
|
||||||
|
|
||||||
|
/** Formulario ciudadano */
|
||||||
|
const form = ref({
|
||||||
|
rfc: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
name: '',
|
||||||
|
business_name: '',
|
||||||
|
fiscal_postal_code: '',
|
||||||
|
address: '',
|
||||||
|
fiscal_regimen_id: null,
|
||||||
|
cfdi_use_id: null,
|
||||||
|
selectedRegime: null,
|
||||||
|
selectedCfdiUse: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** HTTP sin autenticación */
|
||||||
|
const publicGet = (path, params = {}) =>
|
||||||
|
axios.get(apiURL(path), { params, headers: { Accept: 'application/json' } });
|
||||||
|
|
||||||
|
const publicPost = (path, data = {}) =>
|
||||||
|
axios.post(apiURL(path), data, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } });
|
||||||
|
|
||||||
|
/** Al cargar — obtener datos del pago */
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet(`invoice-request/payment/${qrToken}`);
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
paymentData.value = res.model;
|
||||||
|
hasInvoiceReq.value = res.has_invoice_request;
|
||||||
|
invoiceRequest.value = res.model?.invoice_request ?? null;
|
||||||
|
|
||||||
|
// Prellenar RFC si el pago tiene uno registrado
|
||||||
|
if (res.model?.model?.rfc) {
|
||||||
|
form.value.rfc = res.model.model.rfc;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
notFound.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar regímenes fiscales en paralelo
|
||||||
|
loadFiscalRegimes();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadFiscalRegimes = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet('sat/fiscal-regimes');
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
fiscalRegimes.value = res.models?.data ?? res.models ?? [];
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Al cambiar régimen — obtener usos de CFDI compatibles */
|
||||||
|
const onRegimeChange = async (regime) => {
|
||||||
|
form.value.fiscal_regimen_id = regime?.id ?? null;
|
||||||
|
form.value.cfdi_use_id = null;
|
||||||
|
form.value.selectedCfdiUse = null;
|
||||||
|
cfdiUses.value = [];
|
||||||
|
|
||||||
|
if (!regime?.code) return;
|
||||||
|
|
||||||
|
loadingCfdi.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet('sat/cfdi-uses', { fiscal_regime: regime.code });
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
cfdiUses.value = res.models?.data ?? res.models ?? [];
|
||||||
|
} catch {
|
||||||
|
cfdiUses.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingCfdi.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCfdiChange = (use) => {
|
||||||
|
form.value.cfdi_use_id = use?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validaciones básicas del cliente */
|
||||||
|
const rfcRegex = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i;
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const errors = {};
|
||||||
|
const rfc = form.value.rfc.trim().toUpperCase();
|
||||||
|
if (!rfc) {
|
||||||
|
errors.rfc = 'El RFC es obligatorio.';
|
||||||
|
} else if (!rfcRegex.test(rfc)) {
|
||||||
|
errors.rfc = 'El RFC no tiene un formato válido (ej. XAXX010101000).';
|
||||||
|
}
|
||||||
|
if (!form.value.email.trim()) errors.email = 'El correo electrónico es obligatorio.';
|
||||||
|
if (!form.value.fiscal_regimen_id) errors.fiscal_regimen_id = 'Selecciona un régimen fiscal.';
|
||||||
|
if (!form.value.cfdi_use_id) errors.cfdi_use_id = 'Selecciona el uso del CFDI.';
|
||||||
|
if (!form.value.fiscal_postal_code.trim()) {
|
||||||
|
errors.fiscal_postal_code = 'El código postal fiscal es obligatorio.';
|
||||||
|
} else if (!/^\d{5}$/.test(form.value.fiscal_postal_code.trim())) {
|
||||||
|
errors.fiscal_postal_code = 'El código postal debe tener exactamente 5 dígitos.';
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Enviar solicitud */
|
||||||
|
const submitRequest = async () => {
|
||||||
|
serverErrors.value = {};
|
||||||
|
const clientErrors = validate();
|
||||||
|
if (Object.keys(clientErrors).length) {
|
||||||
|
serverErrors.value = clientErrors;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
qr_token: qrToken,
|
||||||
|
rfc: form.value.rfc.trim().toUpperCase(),
|
||||||
|
email: form.value.email.trim(),
|
||||||
|
fiscal_regimen_id: form.value.fiscal_regimen_id,
|
||||||
|
cfdi_use_id: form.value.cfdi_use_id,
|
||||||
|
fiscal_postal_code: form.value.fiscal_postal_code.trim(),
|
||||||
|
};
|
||||||
|
if (form.value.phone.trim()) payload.phone = form.value.phone.trim();
|
||||||
|
if (form.value.name.trim()) payload.name = form.value.name.trim();
|
||||||
|
if (form.value.business_name.trim()) payload.business_name = form.value.business_name.trim();
|
||||||
|
if (form.value.address.trim()) payload.address = form.value.address.trim();
|
||||||
|
|
||||||
|
await publicPost('invoice-request', payload);
|
||||||
|
submitted.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === 422) {
|
||||||
|
serverErrors.value = err.response.data?.errors ?? {};
|
||||||
|
} else if (status === 409) {
|
||||||
|
hasInvoiceReq.value = true;
|
||||||
|
invoiceRequest.value = { status: 'pending' };
|
||||||
|
} else if (status === 404) {
|
||||||
|
notFound.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (a) =>
|
||||||
|
a ? new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(a) : '—';
|
||||||
|
|
||||||
|
const formatDate = (iso) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) : '—';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100 flex flex-col">
|
||||||
|
|
||||||
|
<!-- Encabezado -->
|
||||||
|
<header class="bg-[#621134] text-white py-4 px-6 shadow">
|
||||||
|
<div class="max-w-2xl mx-auto flex items-center gap-3">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-3xl text-white"
|
||||||
|
name="receipt_long"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-light opacity-80">H. Ayuntamiento de Comalcalco</p>
|
||||||
|
<h1 class="text-lg font-bold leading-tight">Solicitud de Factura Electrónica</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Contenido principal -->
|
||||||
|
<main class="flex-1 py-8 px-4">
|
||||||
|
<div class="max-w-2xl mx-auto space-y-5">
|
||||||
|
|
||||||
|
<!-- Cargando -->
|
||||||
|
<div v-if="loading" class="bg-white rounded-xl shadow p-10 flex justify-center">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-4 border-[#621134] border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR inválido / no encontrado -->
|
||||||
|
<div v-else-if="notFound" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
|
||||||
|
<span class="material-symbols-outlined text-5xl text-red-400">qr_code_scanner</span>
|
||||||
|
<h2 class="text-lg font-bold text-gray-700">Código QR no válido</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
El código QR no es válido o ya expiró. Si crees que es un error, comunícate con el
|
||||||
|
departamento de Tránsito Municipal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Solicitud enviada exitosamente -->
|
||||||
|
<div v-else-if="submitted" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-5xl text-green-500"
|
||||||
|
name="check_circle"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-bold text-gray-700">¡Solicitud enviada!</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud de factura fue recibida correctamente. Te notificaremos cuando esté lista.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado de solicitud existente -->
|
||||||
|
<template v-else-if="hasInvoiceReq && invoiceRequest">
|
||||||
|
|
||||||
|
<!-- Datos del pago -->
|
||||||
|
<div v-if="paymentData" class="bg-white rounded-xl shadow p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pago de referencia
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje de estado -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-6 text-center space-y-2">
|
||||||
|
<template v-if="invoiceRequest.status === 'pending'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-yellow-500"
|
||||||
|
name="hourglass_empty"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Solicitud en espera</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud fue recibida y está en espera de procesarse.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'processing'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-blue-500"
|
||||||
|
name="sync"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">En proceso</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu factura está siendo generada, pronto te notificaremos.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'completed'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-green-500"
|
||||||
|
name="task_alt"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Factura enviada</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu factura electrónica fue generada y enviada a tu WhatsApp. Revisa tus mensajes.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'rejected'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-red-400"
|
||||||
|
name="cancel"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Solicitud no procesada</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud no pudo procesarse. Comunícate con el departamento de Tránsito Municipal.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Formulario ciudadano -->
|
||||||
|
<template v-else-if="paymentData">
|
||||||
|
|
||||||
|
<!-- Datos del pago -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pago de referencia
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conceptos -->
|
||||||
|
<div v-if="paymentData.model?.charge_concepts?.length" class="mt-3 pt-3 border-t">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Conceptos cobrados:</p>
|
||||||
|
<div
|
||||||
|
v-for="cc in paymentData.model.charge_concepts"
|
||||||
|
:key="cc.id"
|
||||||
|
class="flex justify-between text-xs text-gray-700 py-0.5"
|
||||||
|
>
|
||||||
|
<span>{{ cc.name }} <span class="text-gray-400">({{ cc.article }})</span></span>
|
||||||
|
<span class="font-medium">{{ formatAmount(cc.pivot?.override_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 class="text-base font-bold text-gray-700 mb-4">Datos para tu factura</h2>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submitRequest">
|
||||||
|
|
||||||
|
<!-- RFC -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
RFC <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.rfc"
|
||||||
|
type="text"
|
||||||
|
maxlength="13"
|
||||||
|
placeholder="Ej. XAXX010101000"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.rfc ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.rfc" class="text-xs text-red-500 mt-1">{{ serverErrors.rfc }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Correo electrónico <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tucorreo@ejemplo.com"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.email ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.email" class="text-xs text-red-500 mt-1">{{ serverErrors.email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Teléfono -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Teléfono celular
|
||||||
|
<span class="text-gray-400 font-normal text-xs">(opcional — para recibir la factura por WhatsApp)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.phone"
|
||||||
|
type="tel"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="10 dígitos"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre completo <span class="text-gray-400 font-normal text-xs">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Como aparece en tu constancia fiscal"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Razón social -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Razón social <span class="text-gray-400 font-normal text-xs">(opcional, solo para personas morales)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.business_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nombre de la empresa"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Régimen fiscal -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Régimen fiscal <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
|
||||||
|
:class="serverErrors.fiscal_regimen_id ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
@change="onRegimeChange(fiscalRegimes.find(r => r.id == $event.target.value))"
|
||||||
|
>
|
||||||
|
<option value="">Selecciona tu régimen fiscal…</option>
|
||||||
|
<option
|
||||||
|
v-for="regime in fiscalRegimes"
|
||||||
|
:key="regime.id"
|
||||||
|
:value="regime.id"
|
||||||
|
>
|
||||||
|
{{ regime.code }} — {{ regime.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="serverErrors.fiscal_regimen_id" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_regimen_id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uso del CFDI -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Uso del CFDI <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="loadingCfdi" class="text-sm text-gray-400 py-2">Cargando usos disponibles…</div>
|
||||||
|
<select
|
||||||
|
v-else
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
|
||||||
|
:class="serverErrors.cfdi_use_id ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
:disabled="!form.fiscal_regimen_id"
|
||||||
|
@change="onCfdiChange(cfdiUses.find(u => u.id == $event.target.value))"
|
||||||
|
>
|
||||||
|
<option value="">{{ form.fiscal_regimen_id ? 'Selecciona el uso…' : 'Primero selecciona el régimen' }}</option>
|
||||||
|
<option
|
||||||
|
v-for="use in cfdiUses"
|
||||||
|
:key="use.id"
|
||||||
|
:value="use.id"
|
||||||
|
>
|
||||||
|
{{ use.code }} — {{ use.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="serverErrors.cfdi_use_id" class="text-xs text-red-500 mt-1">{{ serverErrors.cfdi_use_id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Código postal fiscal -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Código postal fiscal <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.fiscal_postal_code"
|
||||||
|
type="text"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="5 dígitos"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.fiscal_postal_code ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.fiscal_postal_code" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_postal_code }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dirección -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Dirección <span class="text-gray-400 font-normal text-xs">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Calle, número, colonia"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón enviar -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full py-3 rounded-lg text-white font-semibold text-sm bg-[#621134] hover:bg-[#7a1540] disabled:opacity-50 transition-colors"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting">Enviando solicitud…</span>
|
||||||
|
<span v-else>Solicitar mi factura</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-center text-gray-400 pb-4">
|
||||||
|
Al enviar aceptas que tus datos sean utilizados únicamente para la emisión de la factura electrónica.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Pie -->
|
||||||
|
<footer class="bg-white border-t py-3 px-4 text-center text-xs text-gray-400">
|
||||||
|
H. Ayuntamiento de Comalcalco — Dirección de Tránsito Municipal
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -112,6 +112,16 @@ const router = createRouter({
|
|||||||
component: () => import('@Pages/App/Checkout/Index.vue')
|
component: () => import('@Pages/App/Checkout/Index.vue')
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invoice-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'invoice-request.index',
|
||||||
|
component: () => import('@Pages/App/InvoiceRequest/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -221,6 +231,11 @@ const router = createRouter({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/factura/:qr_token',
|
||||||
|
name: 'invoice.request',
|
||||||
|
component: () => import('@Pages/Public/Invoice/Index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: '404',
|
name: '404',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user