feat: agregar gestión de facturas con creación, edición y eliminación
This commit is contained in:
parent
847d2af7ef
commit
5dbb52a9e9
@ -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',
|
||||
|
||||
@ -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')"
|
||||
|
||||
153
src/pages/Admin/Bills/Create.vue
Normal file
153
src/pages/Admin/Bills/Create.vue
Normal 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>
|
||||
96
src/pages/Admin/Bills/Delete.vue
Normal file
96
src/pages/Admin/Bills/Delete.vue
Normal 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>
|
||||
181
src/pages/Admin/Bills/Edit.vue
Normal file
181
src/pages/Admin/Bills/Edit.vue
Normal 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>
|
||||
244
src/pages/Admin/Bills/Index.vue
Normal file
244
src/pages/Admin/Bills/Index.vue
Normal 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>
|
||||
21
src/pages/Admin/Bills/Module.js
Normal file
21
src/pages/Admin/Bills/Module.js
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
<FormInput
|
||||
v-model="form.invoice_reference"
|
||||
type="text"
|
||||
placeholder="Ej: FAC-2026-001"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
@ -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,7 +238,30 @@ 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')
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -207,7 +207,7 @@ const api = {
|
||||
})
|
||||
},
|
||||
patch(url, options) {
|
||||
this.load('patch', {
|
||||
this.load({
|
||||
method: 'patch',
|
||||
url,
|
||||
options
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user