Files
soft_usite/resources/views/renovaciones/clientes.html
T
2026-05-14 22:30:31 -05:00

424 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div x-data="app" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<div class="justify-between items-center w-full md:flex mb-4">
<div>
<h1 class="text-2xl font-bold mb-1">Clientes</h1>
<p class="text-sm text-gray-500">Gestión de clientes y sus datos de contacto</p>
</div>
<div class="flex items-center gap-2 mt-3 md:mt-0">
<input type="text" placeholder="Buscar..." class="border border-gray-300 rounded p-2 text-sm"
x-model="search" @input.debounce.400ms="loadData()" />
<button @click="addModal = true" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="text-left border-b border-gray-200 bg-gray-50">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Empresa</th>
<th class="py-2 px-3">Email</th>
<th class="py-2 px-3">Teléfono</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 w-32"></th>
</tr>
</thead>
<tbody class="text-gray-600">
<template x-for="d in datos" :key="d.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="d.nombre"></td>
<td class="py-2 px-3 text-gray-500" x-text="d.empresa||'—'"></td>
<td class="py-2 px-3" x-text="d.email"></td>
<td class="py-2 px-3" x-text="d.telefono||'—'"></td>
<td class="py-2 px-3">
<span :class="d.activo ? 'text-green-600' : 'text-red-500'" x-text="d.activo ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="py-2 px-3">
<div class="flex gap-2">
<button @click="openDocs(d)" title="Documentos" class="text-gray-400 hover:text-blue-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"/></svg>
</button>
<button @click="openEdit(d)" title="Editar" class="text-gray-400 hover:text-[#8eb02f]">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<button @click="openDelete(d)" title="Eliminar" class="text-gray-400 hover:text-red-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
</button>
</div>
</td>
</tr>
</template>
<tr x-show="!loading && datos.length===0">
<td colspan="6" class="text-center text-gray-400 py-8">Sin registros</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-500">
<span>Total: <span x-text="total"></span></span>
<div class="flex gap-1">
<button @click="prevPage()" :disabled="page===1" class="px-3 py-1 rounded border disabled:opacity-40"></button>
<span class="px-3 py-1" x-text="'Pág. '+page+' / '+totalPages"></span>
<button @click="nextPage()" :disabled="page>=totalPages" class="px-3 py-1 rounded border disabled:opacity-40"></button>
</div>
</div>
</div>
<!-- Modal Crear / Editar -->
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6" @click.stop>
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Cliente' : 'Nuevo Cliente'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs font-medium text-gray-600">Nombre *</label>
<input x-model="form.nombre" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Empresa</label>
<input x-model="form.empresa" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Email *</label>
<input x-model="form.email" type="email" required class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Email CC</label>
<input x-model="form.email_cc" type="email" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Teléfono</label>
<input x-model="form.telefono" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs font-medium text-gray-600">Documento</label>
<input x-model="form.documento" class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<div class="col-span-2">
<label class="text-xs font-medium text-gray-600">Notas</label>
<textarea x-model="form.notas" rows="2" class="mt-1 w-full border rounded px-3 py-2 text-sm"></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" x-model="form.activo" id="activo_cli" />
<label for="activo_cli" class="text-sm">Activo</label>
</div>
</div>
<div class="flex justify-end gap-2 mt-5">
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button type="submit" class="px-4 py-2 bg-[#8eb02f] text-white rounded text-sm" :disabled="loading">
<span x-text="loading ? 'Guardando…' : 'Guardar'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal Eliminar -->
<div x-show="deleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
<svg class="w-12 h-12 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"/></svg>
<p class="font-semibold mb-1">¿Eliminar cliente?</p>
<p class="text-sm text-gray-400 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="deleteItem()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
</div>
</div>
</div>
<!-- ─── Modal Documentos ──────────────────────────────────────────────── -->
<div x-show="docsModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 flex flex-col max-h-[90vh]" @click.stop>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b">
<div>
<h2 class="text-lg font-semibold">Documentos</h2>
<p class="text-xs text-gray-400" x-text="'Cliente: ' + (docsCliente?.nombre || '')"></p>
</div>
<button @click="docsModal=false" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Cuerpo scrollable -->
<div class="overflow-y-auto flex-1 px-6 py-4 space-y-4">
<!-- Formulario de subida -->
<div class="border rounded-lg p-4 bg-gray-50">
<p class="text-sm font-medium text-gray-700 mb-3">Subir documentos</p>
<!-- Fecha de expedición compartida -->
<div class="mb-3">
<label class="text-xs font-medium text-gray-600">Fecha de expedición (opcional, aplica a todos los archivos)</label>
<input type="date" x-model="docsUpload.fechaExpedicion"
class="mt-1 w-full border rounded px-3 py-2 text-sm" />
</div>
<!-- Zona de arrastre / selección de archivos -->
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-[#8eb02f] transition-colors"
@click="$refs.fileInput.click()"
@dragover.prevent
@drop.prevent="handleDrop($event)">
<svg class="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
<p class="text-sm text-gray-500">Haz clic o arrastra archivos aquí</p>
<p class="text-xs text-gray-400 mt-1">PDF, Word, Excel, imágenes, ZIP — máx. 20 MB c/u</p>
<input type="file" x-ref="fileInput" multiple class="hidden"
@change="addFiles($event.target.files)" />
</div>
<!-- Lista de archivos pendientes -->
<template x-if="docsUpload.files.length > 0">
<div class="mt-3 space-y-2">
<template x-for="(f, i) in docsUpload.files" :key="i">
<div class="flex items-center gap-3 bg-white border rounded px-3 py-2 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
<span class="flex-1 truncate" x-text="f.name"></span>
<span class="text-gray-400 text-xs" x-text="formatSize(f.size)"></span>
<button @click="removeFile(i)" class="text-red-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
</template>
<button @click="uploadDocs()" :disabled="docsUploading"
class="w-full mt-2 bg-[#8eb02f] text-white py-2 rounded text-sm font-medium disabled:opacity-50">
<span x-text="docsUploading ? 'Subiendo…' : 'Subir ' + docsUpload.files.length + ' archivo(s)'"></span>
</button>
</div>
</template>
</div>
<!-- Lista de documentos guardados -->
<div>
<p class="text-sm font-medium text-gray-700 mb-2">Documentos guardados
<span class="text-gray-400 font-normal" x-text="'(' + documentos.length + ')'"></span>
</p>
<div x-show="docsLoading" class="text-center text-gray-400 text-sm py-4">Cargando…</div>
<div x-show="!docsLoading && documentos.length === 0" class="text-center text-gray-400 text-sm py-4">
Sin documentos
</div>
<div class="space-y-2">
<template x-for="doc in documentos" :key="doc.ID">
<div class="flex items-center gap-3 border rounded px-3 py-2 text-sm hover:bg-gray-50">
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
<div class="flex-1 min-w-0">
<p class="font-medium truncate" x-text="doc.nombre || doc.original_name"></p>
<p class="text-xs text-gray-400">
<span x-text="formatSize(doc.tamanio)"></span>
<template x-if="doc.fecha_expedicion">
<span> · Exp: <span x-text="fmtDate(doc.fecha_expedicion)"></span></span>
</template>
</p>
</div>
<a :href="`/app/api/clientes/${docsCliente.ID}/documentos/${doc.ID}/download`"
target="_blank"
class="text-gray-400 hover:text-blue-500" title="Descargar">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
</a>
<button @click="confirmDeleteDoc(doc)" class="text-gray-400 hover:text-red-500" title="Eliminar">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/></svg>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Confirm eliminar documento -->
<div x-show="deleteDocModal" x-cloak class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 text-center" @click.stop>
<p class="font-semibold mb-1">¿Eliminar documento?</p>
<p class="text-sm text-gray-400 mb-5" x-text="selectedDoc?.nombre || selectedDoc?.original_name"></p>
<div class="flex justify-center gap-3">
<button @click="deleteDocModal=false" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="deleteDoc()" class="px-4 py-2 bg-red-500 text-white rounded text-sm">Eliminar</button>
</div>
</div>
</div>
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
:class="toast.type==='error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
x-text="toast.msg"></div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
loading: false,
datos: [], total: 0, totalPages: 1, page: 1, limit: 10,
search: '',
addModal: false, editModal: false, deleteModal: false,
selectedId: null,
form: { nombre:'', empresa:'', email:'', email_cc:'', telefono:'', documento:'', notas:'', activo:true },
toast: { show: false, msg: '', type: 'ok' },
// ── Documentos ──────────────────────────────────────────────────────
docsModal: false,
docsCliente: null,
documentos: [],
docsLoading: false,
docsUploading: false,
docsUpload: { fechaExpedicion: '', files: [] },
deleteDocModal: false,
selectedDoc: null,
async init() { await this.loadData(); },
async loadData() {
this.loading = true;
const { data } = await axios.get(`/app/api/clientes?page=${this.page}&limit=${this.limit}&search=${this.search}`);
this.datos = data.registros || [];
this.total = data.total || 0;
this.totalPages = data.totalPages || 1;
this.loading = false;
},
openEdit(d) {
this.form = { nombre: d.nombre, empresa: d.empresa, email: d.email, email_cc: d.email_cc, telefono: d.telefono, documento: d.documento, notas: d.notas, activo: d.activo };
this.selectedId = d.ID;
this.editModal = true;
},
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
closeModals() {
this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null;
this.form = { nombre:'', empresa:'', email:'', email_cc:'', telefono:'', documento:'', notas:'', activo:true };
},
async save() {
this.loading = true;
try {
if (this.editModal) {
await axios.put(`/app/api/clientes/${this.selectedId}`, this.form);
} else {
await axios.post('/app/api/clientes', this.form);
}
this.showToast('Guardado correctamente');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
async deleteItem() {
this.loading = true;
try {
await axios.delete(`/app/api/clientes/${this.selectedId}`);
this.showToast('Eliminado');
this.closeModals();
await this.loadData();
} catch(e) { this.showToast(e.response?.data?.error||'Error', 'error'); }
this.loading = false;
},
prevPage() { if(this.page>1){ this.page--; this.loadData(); } },
nextPage() { if(this.page<this.totalPages){ this.page++; this.loadData(); } },
showToast(msg, type='ok') {
this.toast = { show: true, msg, type };
setTimeout(() => this.toast.show = false, 3000);
},
// ── Documentos: métodos ─────────────────────────────────────────────
async openDocs(d) {
this.docsCliente = d;
this.docsModal = true;
this.docsUpload = { fechaExpedicion: '', files: [] };
await this.loadDocs();
},
async loadDocs() {
this.docsLoading = true;
try {
const { data } = await axios.get(`/app/api/clientes/${this.docsCliente.ID}/documentos`);
this.documentos = data || [];
} catch(e) {
this.showToast('Error cargando documentos', 'error');
}
this.docsLoading = false;
},
addFiles(fileList) {
for (const f of fileList) {
if (!this.docsUpload.files.find(x => x.name === f.name && x.size === f.size)) {
this.docsUpload.files.push(f);
}
}
},
handleDrop(event) {
this.addFiles(event.dataTransfer.files);
},
removeFile(i) {
this.docsUpload.files.splice(i, 1);
},
async uploadDocs() {
if (this.docsUpload.files.length === 0) return;
this.docsUploading = true;
try {
const fd = new FormData();
for (const f of this.docsUpload.files) {
fd.append('archivos', f);
}
if (this.docsUpload.fechaExpedicion) {
fd.append('fecha_expedicion', this.docsUpload.fechaExpedicion);
}
await axios.post(`/app/api/clientes/${this.docsCliente.ID}/documentos`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.showToast('Archivos subidos correctamente');
this.docsUpload.files = [];
this.docsUpload.fechaExpedicion = '';
this.$refs.fileInput && (this.$refs.fileInput.value = '');
await this.loadDocs();
} catch(e) {
this.showToast(e.response?.data?.error || 'Error al subir', 'error');
}
this.docsUploading = false;
},
confirmDeleteDoc(doc) {
this.selectedDoc = doc;
this.deleteDocModal = true;
},
async deleteDoc() {
try {
await axios.delete(`/app/api/clientes/${this.docsCliente.ID}/documentos/${this.selectedDoc.ID}`);
this.showToast('Documento eliminado');
this.deleteDocModal = false;
this.selectedDoc = null;
await this.loadDocs();
} catch(e) {
this.showToast(e.response?.data?.error || 'Error al eliminar', 'error');
}
},
formatSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/(1024*1024)).toFixed(1) + ' MB';
},
fmtDate(iso) {
if (!iso) return '';
return iso.substring(0, 10);
},
}));
});
</script>