444 lines
18 KiB
TypeScript
444 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { useTenant } from '../../contexts/TenantContext';
|
|
import toast from 'react-hot-toast';
|
|
import { PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
|
import LpaltoModal from '../../components/modals/LpaltoModal';
|
|
import logger from '../../utils/logger';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
interface LpaltoEntry {
|
|
_id: string;
|
|
rut: string;
|
|
nombre: string;
|
|
tipoPersona: 'Empresa' | 'Persona';
|
|
tipoLista: string;
|
|
nombreSubLista: string;
|
|
fechaIncorporacion: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const API_URL = globalThis.__APP_ENV__?.VITE_API_BASE_URL || 'https://duxiter.azurianlab.com/api';
|
|
|
|
const TenantLpalto = () => {
|
|
const { tenant } = useTenant();
|
|
const { t } = useTranslation();
|
|
const [entries, setEntries] = useState<LpaltoEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [selectedEntry, setSelectedEntry] = useState<LpaltoEntry | null>(null);
|
|
const [tipoPersonaFilter, setTipoPersonaFilter] = useState<string>('');
|
|
const [nombreSubListaFilter, setNombreSubListaFilter] = useState<string>('');
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// Sanitize RUT: remove dots, keep dash
|
|
const sanitizeRut = (rut: string): string => {
|
|
return rut.replace(/\./g, '');
|
|
};
|
|
|
|
// Fetch entries
|
|
const fetchEntries = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const token = localStorage.getItem('token');
|
|
|
|
// Build query parameters
|
|
const queryParams = new URLSearchParams();
|
|
if (search) {
|
|
// If search looks like a RUT, sanitize it
|
|
if (search.includes('.') || search.includes('-')) {
|
|
queryParams.append('search', sanitizeRut(search));
|
|
} else {
|
|
queryParams.append('search', search);
|
|
}
|
|
}
|
|
if (tipoPersonaFilter) queryParams.append('tipoPersona', tipoPersonaFilter);
|
|
if (nombreSubListaFilter) queryParams.append('nombreSubLista', nombreSubListaFilter);
|
|
|
|
const response = await fetch(`${API_URL}/lpalto?${queryParams.toString()}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(t('lpaltoPage.fetchError'));
|
|
}
|
|
|
|
const data = await response.json();
|
|
setEntries(data);
|
|
} catch (error) {
|
|
logger.error('Error fetching entries:', error);
|
|
toast.error(error instanceof Error ? error.message : t('lpaltoPage.fetchErrorGeneric'));
|
|
setEntries([]); // Ensure entries is an array to prevent .map error
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchEntries();
|
|
}, [search, tipoPersonaFilter, nombreSubListaFilter]);
|
|
|
|
// Delete entry
|
|
const deleteEntry = async (id: string) => {
|
|
if (!window.confirm(t('lpaltoPage.confirmDeleteMessage'))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await fetch(`${API_URL}/lpalto/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(t('lpaltoPage.deleteError'));
|
|
}
|
|
|
|
toast.success(t('lpaltoPage.deleteSuccess'));
|
|
fetchEntries(); // Refresh the list
|
|
} catch (error) {
|
|
logger.error('Error deleting entry:', error);
|
|
toast.error(t('lpaltoPage.deleteError'));
|
|
}
|
|
};
|
|
|
|
// Delete multiple entries
|
|
const deleteSelectedEntries = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
|
|
if (!window.confirm(t('lpaltoPage.confirmDeleteMessage'))) {
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
const token = localStorage.getItem('token');
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (const id of selectedIds) {
|
|
try {
|
|
const response = await fetch(`${API_URL}/lpalto/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
successCount++;
|
|
} else {
|
|
errorCount++;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error deleting entry:', error);
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
setIsDeleting(false);
|
|
setSelectedIds([]);
|
|
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount} registros eliminados`);
|
|
}
|
|
if (errorCount > 0) {
|
|
toast.error(`${errorCount} registros no pudieron ser eliminados`);
|
|
}
|
|
|
|
fetchEntries();
|
|
};
|
|
|
|
// Toggle individual checkbox
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedIds(prev =>
|
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
|
);
|
|
};
|
|
|
|
// Toggle all checkboxes
|
|
const toggleSelectAll = () => {
|
|
if (selectedIds.length === entries.length) {
|
|
setSelectedIds([]);
|
|
} else {
|
|
setSelectedIds(entries.map(entry => entry._id));
|
|
}
|
|
};
|
|
|
|
// Get unique sub-list names for filter dropdown
|
|
const uniqueSubListas = Array.from(new Set(entries.map(entry => entry.nombreSubLista)));
|
|
|
|
return (
|
|
<div className="px-4 sm:px-6 lg:px-8 text-gray-600 dark:text-gray-400">
|
|
<div className="sm:flex sm:items-center">
|
|
<div className="sm:flex-auto">
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">{t('lpaltoPage.title')}</h1>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
{t('lpaltoPage.subtitle')}
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const headers = ['RUT','Nombre','TipoPersona','TipoLista','NombreSubLista','FechaIncorporacion'];
|
|
const rows = [
|
|
['12.345.678-9','Juan Pérez','Persona','Lista Interna','Sospechas','2024-01-15'],
|
|
['76.543.210-1','Empresa XYZ S.A.','Empresa','Lista Interna','Morosidad','2023-12-01']
|
|
];
|
|
const esc = (v: string) => '"' + v.replace(/"/g, '""') + '"';
|
|
const csv = [headers.join(';'), ...rows.map(r => r.map(esc).join(';'))].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'plantilla_lpalto.csv';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
className="inline-flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50"
|
|
>
|
|
{t('lpaltoPage.downloadCsvTemplate')}
|
|
</button>
|
|
<label className="inline-flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept=".csv,.xls,.xlsx"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const token = localStorage.getItem('token');
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const toastId = toast.loading(t('lpaltoPage.uploading'));
|
|
try {
|
|
const res = await fetch(`${API_URL}/lpalto/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: formData
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data?.error || t('lpaltoPage.uploadError'));
|
|
}
|
|
toast.success(t('lpaltoPage.uploadSuccess', { saved: data.saved, duplicates: data.duplicates }), { id: toastId });
|
|
fetchEntries();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || t('lpaltoPage.uploadError'), { id: toastId });
|
|
} finally {
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
}}
|
|
/>
|
|
{t('lpaltoPage.uploadCsvXls')}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(true)}
|
|
className="inline-flex items-center justify-center rounded-md border border-transparent bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 sm:w-auto"
|
|
>
|
|
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
|
|
{t('lpaltoPage.addRecord')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
{t('lpaltoPage.formatHint')}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mt-8 flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<div className="relative rounded-md shadow-sm">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('lpaltoPage.searchPlaceholder')}
|
|
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 pl-10 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="sm:w-48">
|
|
<select
|
|
value={tipoPersonaFilter}
|
|
onChange={(e) => setTipoPersonaFilter(e.target.value)}
|
|
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
|
|
>
|
|
<option value="">{t('lpaltoPage.filterAllTypes')}</option>
|
|
<option value="Empresa">{t('lpaltoModal.fields.tipoPersona')} - Empresa</option>
|
|
<option value="Persona">{t('lpaltoModal.fields.tipoPersona')} - Persona</option>
|
|
</select>
|
|
</div>
|
|
<div className="sm:w-64">
|
|
<select
|
|
value={nombreSubListaFilter}
|
|
onChange={(e) => setNombreSubListaFilter(e.target.value)}
|
|
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
|
|
>
|
|
<option value="">{t('lpaltoPage.filterAllSubLists')}</option>
|
|
{uniqueSubListas.map((subLista) => (
|
|
<option key={subLista} value={subLista}>{subLista}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete selected button */}
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={deleteSelectedEntries}
|
|
disabled={isDeleting || selectedIds.length === 0}
|
|
className={`inline-flex items-center justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 ${
|
|
selectedIds.length === 0
|
|
? 'bg-gray-400 cursor-not-allowed'
|
|
: 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
|
}`}
|
|
>
|
|
{isDeleting ? 'Eliminando...' : `Eliminar seleccionados (${selectedIds.length})`}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Entries Table */}
|
|
<div className="mt-8 flex flex-col">
|
|
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
|
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
|
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-5 md:rounded-lg">
|
|
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
|
|
<thead className="bg-gray-100 dark:bg-gray-800">
|
|
<tr>
|
|
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6">
|
|
<input
|
|
type="checkbox"
|
|
checked={entries.length > 0 && selectedIds.length === entries.length}
|
|
onChange={toggleSelectAll}
|
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
|
|
</th>
|
|
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6">
|
|
{t('lpaltoPage.headers.rut')}
|
|
</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('lpaltoPage.headers.nombre')}
|
|
</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('lpaltoPage.headers.tipoPersona')}
|
|
</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('lpaltoPage.headers.tipoLista')}
|
|
</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('lpaltoPage.headers.nombreSubLista')}
|
|
</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('lpaltoPage.headers.fechaIncorporacion')}
|
|
</th>
|
|
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
|
<span className="sr-only">{t('lpaltoPage.headers.acciones')}</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-4 text-center text-gray-600 dark:text-gray-400">
|
|
{t('common.loading')}
|
|
</td>
|
|
</tr>
|
|
) : entries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-4 text-center text-gray-600 dark:text-gray-400">
|
|
{t('common.noResults')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
entries.map((entry) => (
|
|
<tr key={entry._id}>
|
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-white sm:pl-6">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(entry._id)}
|
|
onChange={() => toggleSelect(entry._id)}
|
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
</td>
|
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-white sm:pl-6">
|
|
{entry.rut}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
{entry.nombre}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
{entry.tipoPersona}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
{entry.tipoLista}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
{entry.nombreSubLista}
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
{new Date(entry.fechaIncorporacion).toLocaleDateString('es-CL')}
|
|
</td>
|
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedEntry(entry);
|
|
setShowEditModal(true);
|
|
}}
|
|
className="text-primary-500 hover:text-primary-400 mr-4"
|
|
>
|
|
{t('lpaltoPage.edit')}
|
|
</button>
|
|
<button
|
|
onClick={() => deleteEntry(entry._id)}
|
|
className="text-red-500 hover:text-red-400"
|
|
>
|
|
{t('common.delete')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add/Edit Modal */}
|
|
<LpaltoModal
|
|
isOpen={showAddModal || showEditModal}
|
|
onClose={() => {
|
|
setShowAddModal(false);
|
|
setShowEditModal(false);
|
|
setSelectedEntry(null);
|
|
}}
|
|
entry={selectedEntry || undefined}
|
|
onSuccess={fetchEntries}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TenantLpalto;
|