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 {
|
export default {
|
||||||
'&':'y',
|
'&':'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: {
|
account: {
|
||||||
delete: {
|
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.',
|
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',
|
clientTiers: 'Niveles de Clientes',
|
||||||
billingRequests: 'Solicitudes de Facturación',
|
billingRequests: 'Solicitudes de Facturación',
|
||||||
warehouses: 'Almacenes',
|
warehouses: 'Almacenes',
|
||||||
movements: 'Movimientos'
|
movements: 'Movimientos',
|
||||||
|
bills: 'Facturas / Gastos'
|
||||||
},
|
},
|
||||||
cashRegister: {
|
cashRegister: {
|
||||||
title: 'Caja Registradora',
|
title: 'Caja Registradora',
|
||||||
|
|||||||
@ -118,6 +118,12 @@ onMounted(() => {
|
|||||||
name="pos.billingRequests"
|
name="pos.billingRequests"
|
||||||
to="pos.billingRequests.index"
|
to="pos.billingRequests.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
v-if="hasPermission('bills.index')"
|
||||||
|
icon="finance"
|
||||||
|
name="Facturas / Gastos"
|
||||||
|
to="admin.bills.index"
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section
|
<Section
|
||||||
v-if="hasPermission('users.index')"
|
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 FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import SerialInputList from '@Components/POS/SerialInputList.vue';
|
import SerialInputList from '@Components/POS/SerialInputList.vue';
|
||||||
|
import BillCreateModal from '@Pages/Admin/Bills/Create.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
@ -18,6 +19,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
|
const showBillModal = ref(false);
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const suppliers = 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">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
REFERENCIA DE FACTURA
|
REFERENCIA DE FACTURA
|
||||||
</label>
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.invoice_reference"
|
v-model="form.invoice_reference"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej: FAC-2026-001"
|
placeholder="Ej: FAC-2026-001"
|
||||||
required
|
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" />
|
<FormError :message="form.errors?.invoice_reference" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -674,4 +687,10 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<BillCreateModal
|
||||||
|
:show="showBillModal"
|
||||||
|
@close="showBillModal = false"
|
||||||
|
@created="(bill) => { form.invoice_reference = bill.name; showBillModal = false; }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -141,6 +141,12 @@ const router = createRouter({
|
|||||||
name: 'pos.unitMeasure.index',
|
name: 'pos.unitMeasure.index',
|
||||||
beforeEnter: (to, from, next) => can(next, 'units.index'),
|
beforeEnter: (to, from, next) => can(next, 'units.index'),
|
||||||
component: () => import('@Pages/POS/UnitMeasure/Index.vue')
|
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')
|
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',
|
path: '/changelogs',
|
||||||
component: () => import('@Layouts/AppLayout.vue'),
|
component: () => import('@Layouts/AppLayout.vue'),
|
||||||
|
|||||||
@ -207,7 +207,7 @@ const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
patch(url, options) {
|
patch(url, options) {
|
||||||
this.load('patch', {
|
this.load({
|
||||||
method: 'patch',
|
method: 'patch',
|
||||||
url,
|
url,
|
||||||
options
|
options
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user