feat: agregar gestión de facturas con creación, edición y eliminación

This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-21 11:49:00 -06:00
parent 847d2af7ef
commit 5dbb52a9e9
10 changed files with 788 additions and 11 deletions

View File

@ -1,7 +1,34 @@
import { success } from "toastr";
export default {
'&':'y',
bills: {
title: 'Facturas / Gastos',
name: 'Nombre',
cost: 'Costo',
file: 'Archivo (PDF/Imagen)',
deadline: 'Fecha límite de pago',
file_replace: 'Reemplazar archivo',
view_file: 'Ver archivo',
current_file: 'Archivo actual',
search: 'Buscar por nombre...',
create: {
title: 'Nueva Factura',
description: 'Registra una nueva factura o gasto. Sube el archivo PDF o imagen correspondiente.',
},
edit: {
title: 'Editar Factura',
description: 'Actualiza los datos de la factura. Si subes un nuevo archivo, el anterior será reemplazado.',
},
paid: 'Pagada',
pending: 'Pendiente',
export: 'Exportar pendientes',
toggle_paid: 'Marcar como pagada',
toggle_unpaid: 'Marcar como pendiente',
delete: {
title: 'Eliminar Factura',
confirm: '¿Estás seguro de que deseas eliminar esta factura?',
warning: 'Esta acción es permanente e incluye el archivo adjunto. No se puede deshacer.',
},
},
account: {
delete: {
confirm:'¿Está seguro de que quiere eliminar su cuenta? Una vez eliminada su cuenta, todos sus recursos y datos se borrarán permanentemente. Por favor, introduzca su contraseña para confirmar que desea eliminar permanentemente su cuenta.',
@ -466,7 +493,8 @@ export default {
clientTiers: 'Niveles de Clientes',
billingRequests: 'Solicitudes de Facturación',
warehouses: 'Almacenes',
movements: 'Movimientos'
movements: 'Movimientos',
bills: 'Facturas / Gastos'
},
cashRegister: {
title: 'Caja Registradora',

View File

@ -118,6 +118,12 @@ onMounted(() => {
name="pos.billingRequests"
to="pos.billingRequests.index"
/>
<Link
v-if="hasPermission('bills.index')"
icon="finance"
name="Facturas / Gastos"
to="admin.bills.index"
/>
</Section>
<Section
v-if="hasPermission('users.index')"

View File

@ -0,0 +1,153 @@
<script setup>
import { useForm, apiURL } from '@Services/Api';
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
cost: '',
deadline: '',
paid: false,
file: null,
});
/** Métodos */
const create = () => {
form.post(apiURL('bills'), {
onSuccess: (data) => {
window.Notify.success(Lang('register.create.onSuccess'));
emit('created', data.bill);
closeModal();
},
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('create.title') }}
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<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>
<!-- Formulario -->
<form @submit.prevent="create" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('name') }}
</label>
<FormInput
v-model="form.name"
type="text"
:placeholder="transl('name')"
autofocus
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Costo -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('cost') }}
</label>
<FormInput
v-model="form.cost"
type="number"
step="0.01"
min="0"
:placeholder="transl('cost')"
required
/>
<FormError :message="form.errors?.cost" />
</div>
<!-- Fecha límite -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('deadline') }}
</label>
<FormInput
v-model="form.deadline"
type="date"
/>
<FormError :message="form.errors?.deadline" />
</div>
<!-- Pagada -->
<div class="col-span-2 flex items-center gap-3">
<input
id="create-paid"
v-model="form.paid"
type="checkbox"
class="w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<label for="create-paid" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
{{ transl('paid') }}
</label>
</div>
<!-- Archivo -->
<div class="col-span-2">
<SingleFile
v-model="form.file"
accept="application/pdf,image/jpeg,image/png,image/jpg"
title="bills.file"
/>
<FormError :message="form.errors?.file" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
{{ $t('cancel') }}
</button>
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="form.processing">{{ $t('saving') }}...</span>
<span v-else>{{ $t('save') }}</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,96 @@
<script setup>
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
show: {
type: Boolean,
default: false
},
bill: {
type: Object,
default: null
}
});
/** Eventos */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => emit('confirm', props.bill.id);
const handleClose = () => emit('close');
</script>
<template>
<Modal :show="show" max-width="md" @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-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('delete.title') }}
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<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>
<!-- Contenido -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
{{ transl('delete.confirm') }}
</p>
<div
v-if="bill"
class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-1"
>
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
{{ bill.name }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
${{ Number(bill.cost).toFixed(2) }}
</p>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
{{ transl('delete.warning') }}
</p>
</div>
</div>
<!-- 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"
class="px-5 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"
>
{{ $t('cancel') }}
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
{{ transl('delete.title') }}
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,181 @@
<script setup>
import { watch } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
bill: Object
});
/** Formulario */
const form = useForm({
name: '',
cost: '',
deadline: '',
paid: false,
file: null,
});
/** Métodos */
const update = () => {
form.post(apiURL(`bills/${props.bill.id}`), {
onSuccess: () => {
window.Notify.success(Lang('register.edit.onSuccess'));
emit('updated');
closeModal();
},
});
};
const closeModal = () => {
form.reset();
emit('close');
};
/** Observadores */
watch(() => props.bill, (bill) => {
if (bill) {
form.name = bill.name || '';
form.cost = bill.cost || '';
form.deadline = bill.deadline || '';
form.paid = !!bill.paid;
form.file = null;
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('edit.title') }}
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<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>
<!-- Formulario -->
<form @submit.prevent="update" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('name') }}
</label>
<FormInput
v-model="form.name"
type="text"
:placeholder="transl('name')"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Costo -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('cost') }}
</label>
<FormInput
v-model="form.cost"
type="number"
step="0.01"
min="0"
:placeholder="transl('cost')"
required
/>
<FormError :message="form.errors?.cost" />
</div>
<!-- Fecha límite -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('deadline') }}
</label>
<FormInput
v-model="form.deadline"
type="date"
/>
<FormError :message="form.errors?.deadline" />
</div>
<!-- Archivo actual -->
<div v-if="bill?.file_url" class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('current_file') }}
</label>
<a
:href="bill.file_url"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/40 transition-colors"
>
<GoogleIcon name="description" class="text-base" />
{{ transl('view_file') }}
</a>
</div>
<!-- Pagada -->
<div class="col-span-2 flex items-center gap-3">
<input
id="edit-paid"
v-model="form.paid"
type="checkbox"
class="w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<label for="edit-paid" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
{{ transl('paid') }}
</label>
</div>
<!-- Nuevo archivo -->
<div class="col-span-2">
<SingleFile
v-model="form.file"
accept="application/pdf,image/jpeg,image/png,image/jpg"
title="bills.file_replace"
/>
<FormError :message="form.errors?.file" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
{{ $t('cancel') }}
</button>
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="form.processing">{{ $t('saving') }}...</span>
<span v-else>{{ $t('update') }}</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,244 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, useApi, apiURL } from '@Services/Api';
import { can, transl } from './Module';
import { formatCurrency } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './Create.vue';
import EditModal from './Edit.vue';
import DeleteModal from './Delete.vue';
/** Estado */
const bills = ref([]);
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const editingBill = ref(null);
const deletingBill = ref(null);
/** Búsqueda */
const searcher = useSearcher({
url: apiURL('bills'),
onSuccess: (r) => bills.value = r.bills,
onError: () => bills.value = []
});
/** Métodos */
const openCreateModal = () => showCreateModal.value = true;
const closeCreateModal = () => showCreateModal.value = false;
const openEditModal = (bill) => {
editingBill.value = bill;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingBill.value = null;
};
const openDeleteModal = (bill) => {
deletingBill.value = bill;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingBill.value = null;
};
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`bills/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
window.Notify.success('Factura eliminada exitosamente');
closeDeleteModal();
searcher.search();
} else {
window.Notify.error('Error al eliminar la factura');
}
} catch (error) {
window.Notify.error('Error al eliminar la factura');
}
};
const onSaved = () => searcher.search();
const api = useApi();
const togglePaid = (bill) => {
api.patch(apiURL(`bills/${bill.id}/toggle-paid`), {
onSuccess: () => searcher.refresh(),
});
};
const exportPending = () => {
api.download(apiURL('bills/pending/excel'), 'Facturas_Pendientes.xlsx');
};
/** Ciclo de vida */
onMounted(() => searcher.search());
</script>
<template>
<div>
<SearcherHead
:title="transl('title')"
:placeholder="transl('search')"
@search="(x) => searcher.search(x)"
>
<button
class="flex items-center gap-2 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="exportPending"
>
<GoogleIcon name="download" class="text-xl" />
{{ transl('export') }}
</button>
<button
v-if="can('create')"
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="openCreateModal"
>
<GoogleIcon name="add" class="text-xl" />
{{ transl('create.title') }}
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="bills"
:processing="searcher.processing"
@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">
{{ transl('name') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('cost') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('deadline') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('file') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('paid') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ $t('actions') }}
</th>
</template>
<template #body="{ items }">
<tr
v-for="bill in items"
:key="bill.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ bill.name }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(bill.cost) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<p v-if="bill.deadline" class="text-sm text-gray-700 dark:text-gray-300">
{{ bill.deadline }}
</p>
<span v-else class="text-sm text-gray-400"></span>
</td>
<td class="px-6 py-4 text-center">
<a
v-if="bill.file_url"
:href="bill.file_url"
target="_blank"
class="inline-flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
>
<GoogleIcon name="description" class="text-base" />
{{ transl('view_file') }}
</a>
<span v-else class="text-sm text-gray-400"></span>
</td>
<td class="px-6 py-4 text-center">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold transition-colors"
:class="bill.paid
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'"
:title="bill.paid ? transl('toggle_unpaid') : transl('toggle_paid')"
@click.stop="togglePaid(bill)"
>
<GoogleIcon :name="bill.paid ? 'check_circle' : 'schedule'" class="text-base" />
{{ bill.paid ? transl('paid') : transl('pending') }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="can('edit')"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
:title="$t('crud.edit')"
@click.stop="openEditModal(bill)"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
:title="$t('crud.destroy')"
@click.stop="openDeleteModal(bill)"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="6" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon name="receipt_long" class="text-6xl mb-2 opacity-50" />
<p class="font-semibold">{{ $t('registers.empty') }}</p>
</div>
</td>
</template>
</Table>
</div>
<CreateModal
v-if="can('create')"
:show="showCreateModal"
@close="closeCreateModal"
@created="onSaved"
/>
<EditModal
v-if="can('edit')"
:show="showEditModal"
:bill="editingBill"
@close="closeEditModal"
@updated="onSaved"
/>
<DeleteModal
v-if="can('destroy')"
:show="showDeleteModal"
:bill="deletingBill"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
</div>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`admin.bills.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.bills.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`bills.${str}`)
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`bills.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -8,6 +8,7 @@ import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialInputList from '@Components/POS/SerialInputList.vue';
import BillCreateModal from '@Pages/Admin/Bills/Create.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
@ -18,6 +19,7 @@ const props = defineProps({
});
/** Estado */
const showBillModal = ref(false);
const products = ref([]);
const warehouses = ref([]);
const suppliers = ref([]);
@ -628,12 +630,23 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REFERENCIA DE FACTURA
</label>
<div class="flex items-center gap-2">
<FormInput
v-model="form.invoice_reference"
type="text"
placeholder="Ej: FAC-2026-001"
required
class="flex-1"
/>
<button
type="button"
@click="showBillModal = true"
class="flex items-center justify-center w-9 h-9 shrink-0 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
title="Crear nueva factura"
>
<GoogleIcon name="add" class="text-lg" />
</button>
</div>
<FormError :message="form.errors?.invoice_reference" />
</div>
@ -674,4 +687,10 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
</form>
</div>
</Modal>
<BillCreateModal
:show="showBillModal"
@close="showBillModal = false"
@created="(bill) => { form.invoice_reference = bill.name; showBillModal = false; }"
/>
</template>

View File

@ -141,6 +141,12 @@ const router = createRouter({
name: 'pos.unitMeasure.index',
beforeEnter: (to, from, next) => can(next, 'units.index'),
component: () => import('@Pages/POS/UnitMeasure/Index.vue')
},
{
path: 'bills',
name: 'admin.bills.index',
beforeEnter: (to, from, next) => can(next, 'bills.index'),
component: () => import('@Pages/Admin/Bills/Index.vue')
}
]
},
@ -232,9 +238,32 @@ const router = createRouter({
component: () => import('@Pages/Admin/Activities/Index.vue')
}
]
},
{
path: 'bills',
children: [
{
path: '',
name: 'admin.bills.index',
beforeEnter: (to, from, next) => can(next, 'bills.index'),
component: () => import('@Pages/Admin/Bills/Index.vue')
},
{
path: 'create',
name: 'admin.bills.create',
beforeEnter: (to, from, next) => can(next, 'bills.create'),
component: () => import('@Pages/Admin/Bills/Create.vue')
},
{
path: ':id/edit',
name: 'admin.bills.edit',
beforeEnter: (to, from, next) => can(next, 'bills.edit'),
component: () => import('@Pages/Admin/Bills/Edit.vue')
}
]
},
]
},
{
path: '/changelogs',
component: () => import('@Layouts/AppLayout.vue'),

View File

@ -207,7 +207,7 @@ const api = {
})
},
patch(url, options) {
this.load('patch', {
this.load({
method: 'patch',
url,
options