Feat: Cambios Varios 2

This commit is contained in:
2026-01-05 10:30:04 -03:00
parent 8bc1308bc5
commit 0fa77e4a98
184 changed files with 11098 additions and 6348 deletions

View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5176/api
VITE_BASE_URL=http://localhost:5176

View File

@@ -1,6 +1,34 @@
import { X, Printer, Globe, Tag, Image as ImageIcon, Info, User } from 'lucide-react';
export default function ListingDetailModal({ isOpen, onClose, detail }: any) {
interface ListingDetail {
listing: {
id: number;
title: string;
categoryName?: string;
clientName?: string;
clientDni?: string;
adFee: number;
price: number;
status: string;
printText?: string;
printDaysCount?: number;
isBold?: boolean;
isFrame?: boolean;
description?: string;
};
attributes: Array<{ id: number; attributeName: string; value: string }>;
images: Array<{ id: number; url: string }>;
}
export default function ListingDetailModal({
isOpen,
onClose,
detail
}: {
isOpen: boolean;
onClose: () => void;
detail: ListingDetail | null
}) {
if (!isOpen || !detail) return null;
const { listing, attributes, images } = detail;
@@ -53,7 +81,7 @@ export default function ListingDetailModal({ isOpen, onClose, detail }: any) {
<h3 className="text-sm font-bold text-gray-400 uppercase mb-4 flex items-center gap-2"><ImageIcon size={16} /> Galería de Fotos</h3>
{images.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{images.map((img: any) => (
{images.map((img: { id: number; url: string }) => (
<img
key={img.id}
src={getImageUrl(img.url)}
@@ -73,7 +101,7 @@ export default function ListingDetailModal({ isOpen, onClose, detail }: any) {
<section>
<h3 className="text-sm font-bold text-gray-400 uppercase mb-4 flex items-center gap-2"><Info size={16} /> Ficha Técnica</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{attributes.map((attr: any) => (
{attributes.map((attr: { id: number; attributeName: string; value: string }) => (
<div key={attr.id} className="bg-white p-3 rounded-lg border border-gray-100 shadow-sm">
<span className="block text-[10px] font-bold text-gray-400 uppercase">{attr.attributeName}</span>
<span className="font-bold text-gray-700">{attr.value}</span>

View File

@@ -2,8 +2,16 @@ import { useState, useEffect } from 'react';
import api from '../../services/api';
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Clock } from 'lucide-react';
interface AuditLog {
id: number;
action: string;
username: string;
createdAt: string;
details: string;
}
export default function AuditTimeline() {
const [logs, setLogs] = useState<any[]>([]);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { type Category } from '../../types/Category';
import OperationManager from './OperationManager';
import { categoryService } from '../../services/categoryService';
@@ -50,14 +50,20 @@ export default function CategoryManager() {
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
const [targetMergeId, setTargetMergeId] = useState<number>(0);
// State for Move Content (Nueva funcionalidad)
// State for Move Content
const [isMoveContentModalOpen, setIsMoveContentModalOpen] = useState(false);
useEffect(() => {
loadCategories();
const buildTree = useCallback((cats: Category[], parentId: number | null = null, level = 0): CategoryNode[] => {
return cats
.filter(c => c.parentId === parentId)
.map(c => ({
...c,
level,
children: buildTree(cats, c.id, level + 1)
}));
}, []);
const loadCategories = async () => {
const loadCategories = useCallback(async () => {
setIsLoading(true);
try {
const data = await categoryService.getAll();
@@ -69,17 +75,11 @@ export default function CategoryManager() {
} finally {
setIsLoading(false);
}
};
}, [buildTree]);
const buildTree = (cats: Category[], parentId: number | null = null, level = 0): CategoryNode[] => {
return cats
.filter(c => c.parentId === parentId)
.map(c => ({
...c,
level,
children: buildTree(cats, c.id, level + 1)
}));
};
useEffect(() => {
loadCategories();
}, [loadCategories]);
// --- LÓGICA DRAG & DROP ---
@@ -98,6 +98,11 @@ export default function CategoryManager() {
setDragOverNodeId(null);
};
const isDescendant = (sourceNode: CategoryNode, targetId: number): boolean => {
if (sourceNode.children.some(child => child.id === targetId)) return true;
return sourceNode.children.some(child => isDescendant(child, targetId));
};
const handleDragOver = (e: React.DragEvent, targetId: number) => {
e.preventDefault();
e.stopPropagation();
@@ -120,13 +125,11 @@ export default function CategoryManager() {
const newParentId = targetNode ? targetNode.id : null;
// 1. Validación: No mover si es el mismo padre
if (draggedNode.parentId === newParentId) {
setDraggedNode(null);
return;
}
// 2. Validación: No mover dentro de sus propios hijos (Ciclo)
if (targetNode && isDescendant(draggedNode, targetNode.id)) {
alert("Operación inválida: No puedes mover una categoría dentro de sus propios hijos.");
setDraggedNode(null);
@@ -149,23 +152,23 @@ export default function CategoryManager() {
parentId: newParentId
});
loadCategories(); // Recargar árbol si tuvo éxito
} catch (error: any) {
loadCategories();
} catch (error: unknown) {
console.error("Error detallado en Drop:", error);
let errorMessage = "Error al mover la categoría";
// Caso 1: BadRequest simple (string plano desde .NET)
if (typeof error.response?.data === 'string') {
errorMessage = error.response.data;
}
// Caso 2: Objeto JSON con propiedad 'message' o 'title' (ProblemDetails)
else if (error.response?.data) {
errorMessage = error.response.data.message || error.response.data.title || JSON.stringify(error.response.data);
}
// Caso 3: Error de red sin respuesta
else if (error.message) {
errorMessage = error.message;
// Casteo seguro para acceder a las propiedades de Axios
const err = error as {
response?: { data?: string | { message?: string; title?: string } };
message?: string
};
if (typeof err.response?.data === 'string') {
errorMessage = err.response.data;
} else if (err.response?.data) {
errorMessage = err.response.data.message || err.response.data.title || JSON.stringify(err.response.data);
} else if (err.message) {
errorMessage = err.message;
}
alert(`❌ Operación Rechazada:\n${errorMessage}`);
@@ -174,11 +177,6 @@ export default function CategoryManager() {
}
};
const isDescendant = (sourceNode: CategoryNode, targetId: number): boolean => {
if (sourceNode.children.some(child => child.id === targetId)) return true;
return sourceNode.children.some(child => isDescendant(child, targetId));
};
// --- FIN LÓGICA DRAG & DROP ---
const handleCreate = (parentId: number | null = null) => {
@@ -214,10 +212,10 @@ export default function CategoryManager() {
}
setIsModalOpen(false);
loadCategories();
} catch (error: any) {
} catch (error: unknown) {
console.error('Error saving category:', error);
// Mostrar validación del backend (ej: Padre con avisos)
alert(error.response?.data || 'Error al guardar la categoría. Verifique las reglas.');
const err = error as { response?: { data?: string } };
alert(err.response?.data || 'Error al guardar la categoría. Verifique las reglas.');
}
};
@@ -232,17 +230,17 @@ export default function CategoryManager() {
setSelectedCategoryOps(catOps);
};
const loadAttributes = useCallback(async (categoryId: number) => {
const attrs = await attributeService.getByCategoryId(categoryId);
setAttributes(attrs);
}, []);
const handleConfigureAttributes = async (category: Category) => {
setConfiguringCategory(category);
setIsAttrModalOpen(true);
loadAttributes(category.id);
};
const loadAttributes = async (categoryId: number) => {
const attrs = await attributeService.getByCategoryId(categoryId);
setAttributes(attrs);
};
const handleCreateAttribute = async (e: React.FormEvent) => {
e.preventDefault();
if (!configuringCategory || !newAttrData.name) return;
@@ -250,11 +248,13 @@ export default function CategoryManager() {
await attributeService.create({
...newAttrData,
categoryId: configuringCategory.id,
dataType: newAttrData.dataType as any
dataType: newAttrData.dataType as 'text' | 'number' | 'boolean' | 'date'
});
setNewAttrData({ name: '', dataType: 'text', required: false });
loadAttributes(configuringCategory.id);
} catch (error) { console.error(error); }
} catch (error: unknown) {
console.error("Error creando atributo:", error);
}
};
const handleDeleteAttribute = async (id: number) => {
@@ -262,7 +262,9 @@ export default function CategoryManager() {
try {
await attributeService.delete(id);
if (configuringCategory) loadAttributes(configuringCategory.id);
} catch (error) { console.error(error); }
} catch (error: unknown) {
console.error("Error eliminando atributo:", error);
}
};
const toggleOperation = async (opId: number, isChecked: boolean) => {
@@ -276,10 +278,12 @@ export default function CategoryManager() {
await categoryService.removeOperation(configuringCategory.id, opId);
setSelectedCategoryOps(selectedCategoryOps.filter(o => o.id !== opId));
}
} catch (error) { console.error(error); alert("Error actualizando operación"); }
} catch (error: unknown) {
console.error("Error actualizando operación:", error);
alert("Error actualizando operación");
}
};
// --- MERGE LOGIC ---
const handleOpenMerge = (cat: Category) => {
setEditingCategory(cat);
setTargetMergeId(0);
@@ -294,11 +298,13 @@ export default function CategoryManager() {
setIsMergeModalOpen(false);
loadCategories();
alert("Fusión completada.");
} catch (e) { console.error(e); alert("Error al fusionar"); }
} catch (error: unknown) {
console.error("Error en la operación:", error);
alert("Error al procesar la solicitud");
}
}
}
// --- MOVE CONTENT LOGIC (Nuevo) ---
const handleOpenMoveContent = (cat: Category) => {
setEditingCategory(cat);
setTargetMergeId(0);
@@ -308,7 +314,6 @@ export default function CategoryManager() {
const handleMoveContent = async () => {
if (!editingCategory || !targetMergeId) return;
try {
// Usamos el endpoint para mover solo avisos
await api.post('/categories/move-content', {
sourceId: editingCategory.id,
targetId: targetMergeId
@@ -321,7 +326,10 @@ export default function CategoryManager() {
}
}
// Componente Recursivo de Nodo
const isParentCategory = (id: number) => {
return flatCategories.some(c => c.parentId === id);
};
const CategoryItem = ({ node }: { node: CategoryNode }) => {
const [isExpanded, setIsExpanded] = useState(true);
const isDragOver = dragOverNodeId === node.id;
@@ -364,10 +372,7 @@ export default function CategoryManager() {
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleConfigureAttributes(node)} className="p-1 text-orange-600 hover:bg-orange-100 rounded" title="Atributos"><LayoutList size={16} /></button>
<button onClick={() => handleConfigureOps(node)} className="p-1 text-purple-600 hover:bg-purple-100 rounded" title="Operaciones"><Settings size={16} /></button>
{/* Botón Mover Contenido (Nuevo) */}
<button onClick={() => handleOpenMoveContent(node)} className="p-1 text-teal-600 hover:bg-teal-100 rounded" title="Mover Avisos (Vaciar)"><ArrowRightCircle size={16} /></button>
<button onClick={() => handleOpenMerge(node)} className="p-1 text-indigo-600 hover:bg-indigo-100 rounded" title="Fusionar (Merge)"><GitMerge size={16} /></button>
<button onClick={() => handleCreate(node.id)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="Subcategoría"><Plus size={16} /></button>
<button onClick={() => handleEdit(node)} className="p-1 text-blue-600 hover:bg-blue-100 rounded" title="Editar"><Edit size={16} /></button>
@@ -384,11 +389,6 @@ export default function CategoryManager() {
);
};
// Helper para saber si una categoría es padre (tiene hijos)
const isParentCategory = (id: number) => {
return flatCategories.some(c => c.parentId === id);
};
return (
<div>
<div className="flex justify-between items-center mb-6">
@@ -438,7 +438,6 @@ export default function CategoryManager() {
{/* --- MODALES --- */}
{/* Main Modal (Create/Edit) */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
@@ -494,7 +493,6 @@ export default function CategoryManager() {
</form>
</Modal>
{/* Matrix Operations Modal */}
<Modal
isOpen={isOpsModalOpen}
onClose={() => setIsOpsModalOpen(false)}
@@ -525,14 +523,12 @@ export default function CategoryManager() {
</div>
</Modal>
{/* Attributes Configuration Modal */}
<Modal
isOpen={isAttrModalOpen}
onClose={() => setIsAttrModalOpen(false)}
title={`Atributos de: ${configuringCategory?.name}`}
>
<div className="space-y-6">
{/* List Existing Attributes */}
<div>
<h4 className="font-semibold mb-2 text-sm text-gray-700">Atributos Actuales</h4>
{attributes.length === 0 ? (
@@ -555,7 +551,6 @@ export default function CategoryManager() {
)}
</div>
{/* Add New Attribute Form */}
<form onSubmit={handleCreateAttribute} className="border-t pt-4">
<h4 className="font-semibold mb-3 text-sm text-gray-700">Agregar Atributo</h4>
<div className="flex gap-2 mb-2">
@@ -595,7 +590,6 @@ export default function CategoryManager() {
</div>
</Modal>
{/* Merge Modal */}
<Modal isOpen={isMergeModalOpen} onClose={() => setIsMergeModalOpen(false)} title="Fusionar Categorías">
<div className="space-y-4">
<p className="text-sm text-gray-600 bg-yellow-50 p-3 rounded border border-yellow-200">
@@ -631,7 +625,6 @@ export default function CategoryManager() {
</div>
</Modal>
{/* MOVE CONTENT MODAL */}
<Modal isOpen={isMoveContentModalOpen} onClose={() => setIsMoveContentModalOpen(false)} title="Mover Avisos (Vaciar)">
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded border border-blue-200 text-sm text-gray-700">
@@ -650,8 +643,8 @@ export default function CategoryManager() {
<option value="0">-- Seleccionar Destino --</option>
{flatCategories
.filter(c =>
c.id !== editingCategory?.id && // No el mismo
!isParentCategory(c.id) // No permitir mover a Padres
c.id !== editingCategory?.id &&
!isParentCategory(c.id)
)
.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
@@ -659,10 +652,6 @@ export default function CategoryManager() {
</select>
</div>
{targetMergeId === 0 && (
<p className="text-xs text-red-500 mt-1">Debe seleccionar un destino válido.</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setIsMoveContentModalOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancelar</button>
<button
@@ -675,7 +664,6 @@ export default function CategoryManager() {
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { type Operation } from '../../types/Operation';
import { operationService } from '../../services/operationService';
import { Plus, Trash2 } from 'lucide-react';
@@ -7,14 +7,17 @@ export default function OperationManager() {
const [operations, setOperations] = useState<Operation[]>([]);
const [newOpName, setNewOpName] = useState('');
useEffect(() => {
loadOperations();
}, []);
const loadOperations = async () => {
const loadOperations = useCallback(async () => {
const ops = await operationService.getAll();
setOperations(ops);
};
}, []);
useEffect(() => {
const init = async () => {
await loadOperations();
};
init();
}, [loadOperations]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
@@ -24,8 +27,8 @@ export default function OperationManager() {
await operationService.create(newOpName);
setNewOpName('');
loadOperations();
} catch (error) {
console.error(error);
} catch {
console.error("Error al crear la operación");
}
};
@@ -34,9 +37,9 @@ export default function OperationManager() {
try {
await operationService.delete(id);
loadOperations();
} catch (error) {
console.error(error);
}
} catch {
console.error("Error al eliminar la operación");
}
};
return (

View File

@@ -1,95 +1,294 @@
import { useState, useEffect } from 'react';
import { clientService } from '../../services/clientService';
import { User, Search, Phone, Mail, FileText, CreditCard } from 'lucide-react';
import {
User, Search, Phone, Mail, CreditCard,
Edit, MapPin, ExternalLink,
BarChart3, Award, Clock
} from 'lucide-react';
import Modal from '../../components/Modal';
import { useNavigate } from 'react-router-dom';
interface Client {
id: number;
name: string;
dniOrCuit: string;
email?: string;
phone?: string;
address?: string;
totalAds: number;
totalSpent: number;
}
export default function ClientManager() {
const [clients, setClients] = useState<any[]>([]);
const navigate = useNavigate();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
clientService.getAll().then(res => {
setClients(res);
setLoading(false);
});
}, []);
// Estados para Modales
const [selectedClient, setSelectedClient] = useState<any>(null);
const [summary, setSummary] = useState<any>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [showSummaryModal, setShowSummaryModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const filteredClients = clients.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.dniOrCuit.includes(search)
);
useEffect(() => { loadClients(); }, []);
const loadClients = async () => {
setLoading(true);
try {
const res = await clientService.getAll();
setClients(res);
} finally {
setLoading(false);
}
};
const handleOpenSummary = async (client: Client) => {
setSummary(null);
setSelectedClient(client);
setShowSummaryModal(true);
const data = await clientService.getSummary(client.id);
setSummary(data);
};
const handleOpenEdit = (client: Client) => {
setSelectedClient({ ...client });
setShowEditModal(true);
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedClient) return;
setIsSaving(true);
try {
await clientService.update(selectedClient.id, selectedClient);
await loadClients();
setShowEditModal(false);
} finally {
setIsSaving(false);
}
};
const filteredClients = clients.filter(c => {
const name = (c.name || "").toLowerCase();
const dni = (c.dniOrCuit || "").toLowerCase();
const s = search.toLowerCase();
return name.includes(s) || dni.includes(s);
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="text-blue-600" />
Directorio de Clientes
</h2>
</div>
<header className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="text-blue-600" /> Directorio de Clientes
</h2>
<p className="text-sm text-gray-500">Gestión de datos fiscales y analítica de anunciantes</p>
</div>
</header>
{/* Buscador */}
<div className="bg-white p-4 rounded-xl border shadow-sm relative">
<Search className="absolute left-7 top-7 text-gray-400" size={20} />
<input
type="text"
placeholder="Buscar por nombre o DNI/CUIT..."
placeholder="Filtrar por nombre o identificación..."
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Grid de Clientes */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<p className="col-span-full text-center text-gray-400 py-10">Cargando base de datos...</p>
<div className="col-span-full py-20 text-center animate-pulse text-gray-400 font-bold uppercase text-xs tracking-widest">
Sincronizando base de datos...
</div>
) : filteredClients.map(client => (
<div key={client.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all overflow-hidden group">
<div key={client.id} className="bg-white rounded-2xl border border-gray-200 shadow-sm hover:shadow-md transition-all overflow-hidden group">
<div className="p-5 border-b border-gray-50 bg-gray-50/50">
<div className="flex justify-between items-start">
<div className="w-10 h-10 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-bold">
{client.name.charAt(0)}
<div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-bold text-lg">
{(client.name || "?").charAt(0).toUpperCase()}
</div>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest bg-white px-2 py-1 rounded-full border">
ID: #{client.id}
</span>
<button
onClick={() => handleOpenEdit(client)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Edit size={18} />
</button>
</div>
<h3 className="mt-3 font-bold text-gray-800 text-lg line-clamp-1">{client.name}</h3>
<div className="flex items-center gap-1.5 text-xs text-gray-500 mt-1">
<CreditCard size={14} />
{client.dniOrCuit}
<h3 className="mt-3 font-bold text-gray-800 text-lg line-clamp-1 uppercase tracking-tight">{client.name}</h3>
<div className="flex items-center gap-1.5 text-xs text-gray-400 font-bold mt-1">
<CreditCard size={14} /> {client.dniOrCuit}
</div>
</div>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail size={16} className="text-gray-400" />
{client.email || 'Sin correo'}
<Mail size={16} className="text-gray-300" /> {client.email || 'N/A'}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone size={16} className="text-gray-400" />
{client.phone || 'Sin teléfono'}
<Phone size={16} className="text-gray-300" /> {client.phone || 'N/A'}
</div>
<div className="grid grid-cols-2 gap-2 mt-4 pt-4 border-t border-gray-100">
<div className="text-center border-r border-gray-100">
<div className="text-xs text-gray-400 font-bold uppercase">Avisos</div>
<div className="text-lg font-black text-gray-800">{client.totalAds}</div>
<div className="grid grid-cols-2 gap-2 mt-4 pt-4 border-t border-gray-50">
<div className="text-center border-r border-gray-50">
<div className="text-[9px] text-gray-400 font-black uppercase tracking-tighter">Avisos</div>
<div className="text-lg font-black text-slate-800">{client.totalAds}</div>
</div>
<div className="text-center">
<div className="text-xs text-gray-400 font-bold uppercase">Invertido</div>
<div className="text-lg font-black text-green-600">${client.totalSpent?.toLocaleString() || 0}</div>
<div className="text-[9px] text-gray-400 font-black uppercase tracking-tighter">Invertido</div>
<div className="text-lg font-black text-emerald-600">${client.totalSpent?.toLocaleString()}</div>
</div>
</div>
</div>
<button className="w-full py-3 bg-white text-blue-600 text-xs font-bold uppercase tracking-tighter hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center gap-2 border-t">
<FileText size={14} /> Ver Historial Completo
<button
onClick={() => handleOpenSummary(client)}
className="w-full py-3.5 bg-white text-blue-600 text-[10px] font-black uppercase tracking-[0.2em] hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center gap-2 border-t"
>
<BarChart3 size={14} /> Ficha del Anunciante
</button>
</div>
))}
</div>
{/* --- MODAL DE EDICIÓN / FACTURACIÓN --- */}
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Datos Fiscales y de Contacto">
<form onSubmit={handleSaveEdit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Razón Social / Nombre</label>
<input
type="text" required
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.name || ""}
onChange={e => setSelectedClient({ ...selectedClient!, name: e.target.value })}
/>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">DNI / CUIT</label>
<input
type="text" required
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.dniOrCuit || ""}
onChange={e => setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })}
/>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Teléfono</label>
<input
type="text"
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.phone || ""}
onChange={e => setSelectedClient({ ...selectedClient!, phone: e.target.value })}
/>
</div>
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Email</label>
<input
type="email"
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.email || ""}
onChange={e => setSelectedClient({ ...selectedClient!, email: e.target.value })}
/>
</div>
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Dirección de Facturación</label>
<div className="relative">
<MapPin className="absolute left-3 top-3 text-gray-300" size={18} />
<input
type="text"
className="w-full border-2 border-gray-100 p-2.5 pl-10 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.address || ""}
onChange={e => setSelectedClient({ ...selectedClient!, address: e.target.value })}
placeholder="Calle, Nro, Localidad..."
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-6">
<button type="button" onClick={() => setShowEditModal(false)} className="px-6 py-2.5 text-xs font-bold text-gray-500 uppercase tracking-widest">Cancelar</button>
<button
type="submit" disabled={isSaving}
className="bg-blue-600 text-white px-8 py-2.5 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 disabled:opacity-50 transition-all"
>
{isSaving ? "Actualizando..." : "Guardar Cambios"}
</button>
</div>
</form>
</Modal>
{/* --- MODAL DE FICHA INTEGRAL (SUMMARY) --- */}
<Modal
isOpen={showSummaryModal}
onClose={() => setShowSummaryModal(false)}
title="Perfil de Actividad"
>
{summary ? (
<div className="space-y-6">
<div className="flex items-center gap-4 bg-slate-900 p-6 rounded-[2rem] text-white shadow-xl relative overflow-hidden">
<div className="absolute right-0 top-0 p-4 opacity-10"><Award size={100} /></div>
<div className="w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center text-3xl font-black relative z-10">
{summary.Name.charAt(0)}
</div>
<div className="relative z-10">
<h3 className="text-xl font-black uppercase tracking-tight leading-tight">{summary.Name}</h3>
<p className="text-[10px] font-bold text-blue-400 uppercase tracking-[0.2em] mt-1">{summary.DniOrCuit}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-2xl shadow-sm">
<p className="text-[9px] font-black text-emerald-600 uppercase mb-1 tracking-widest">Inversión Total</p>
<p className="text-2xl font-black text-emerald-800 tracking-tighter">${summary.TotalInvested.toLocaleString()}</p>
</div>
<div className="p-4 bg-blue-50 border border-blue-100 rounded-2xl shadow-sm">
<p className="text-[9px] font-black text-blue-600 uppercase mb-1 tracking-widest">Avisos Activos</p>
<p className="text-2xl font-black text-blue-800 tracking-tighter">{summary.ActiveAds}</p>
</div>
<div className="p-4 bg-white border border-gray-100 rounded-2xl shadow-sm">
<p className="text-[9px] font-black text-gray-400 uppercase mb-1 tracking-widest">Avisos Totales</p>
<p className="text-xl font-bold text-gray-700 tracking-tighter">{summary.TotalAds}</p>
</div>
<div className="p-4 bg-white border border-gray-100 rounded-2xl shadow-sm">
<p className="text-[9px] font-black text-gray-400 uppercase mb-1 tracking-widest">Rubro Predilecto</p>
<p className="text-xs font-black text-gray-700 uppercase truncate mt-1">{summary.PreferredCategory}</p>
</div>
</div>
<div className="bg-slate-50 p-6 rounded-[2rem] border border-slate-200 space-y-3">
<div className="flex justify-between items-center text-[10px] font-black uppercase">
<span className="text-gray-400 tracking-widest flex items-center gap-1.5"><Clock size={14} /> Última Publicación</span>
<span className="text-slate-800">{summary.LastAdDate ? new Date(summary.LastAdDate).toLocaleDateString() : 'NUNCA'}</span>
</div>
<div className="flex justify-between items-center text-[10px] font-black uppercase">
<span className="text-gray-400 tracking-widest flex items-center gap-1.5"><Mail size={14} /> Email Registrado</span>
<span className="text-slate-800 truncate max-w-[200px]">{summary.Email || 'S/D'}</span>
</div>
<div className="flex justify-between items-center text-[10px] font-black uppercase">
<span className="text-gray-400 tracking-widest flex items-center gap-1.5"><Phone size={14} /> Teléfono</span>
<span className="text-slate-800">{summary.Phone || 'S/D'}</span>
</div>
</div>
<button
onClick={() => {
navigate(`/listings?q=${summary.DniOrCuit}`);
}}
className="w-full py-4 bg-slate-900 hover:bg-black text-white rounded-2xl font-black uppercase text-[10px] tracking-[0.2em] transition-all flex items-center justify-center gap-3 shadow-xl"
>
<ExternalLink size={18} className="text-blue-500" /> Consultar Avisos en el Explorador
</button>
</div>
) : (
<div className="py-20 text-center flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-400 font-black uppercase text-[10px] tracking-widest">Analizando historial del cliente...</p>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
DollarSign, FileText, Printer, TrendingUp,
Download, PieChart as PieIcon,
@@ -13,6 +13,11 @@ import { dashboardService, type DashboardData } from '../services/dashboardServi
import { useAuthStore } from '../store/authStore';
import api from '../services/api';
import { reportService } from '../services/reportService';
import type {
CashierStat,
RecentTransaction,
AuditLogItem
} from '../types/Dashboard';
const getLocalDateString = (date: Date) => {
const year = date.getFullYear();
@@ -23,20 +28,32 @@ const getLocalDateString = (date: Date) => {
export default function Dashboard() {
const { role } = useAuthStore();
const [data, setData] = useState<DashboardData | any>(null);
const [cashiers, setCashiers] = useState<any[]>([]);
const [recentTransactions, setRecentTransactions] = useState<any[]>([]);
const [data, setData] = useState<DashboardData | null>(null);
const [cashiers, setCashiers] = useState<CashierStat[]>([]);
const [recentTransactions, setRecentTransactions] = useState<RecentTransaction[]>([]);
const [loading, setLoading] = useState(true);
// --- NUEVOS ESTADOS PARA MONITOR INTEGRADO ---
const [selectedCashier, setSelectedCashier] = useState<{ id: number, name: string } | null>(null);
const [selectedCashierLogs, setSelectedCashierLogs] = useState<any[]>([]);
const [selectedCashierLogs, setSelectedCashierLogs] = useState<AuditLogItem[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const todayStr = getLocalDateString(new Date());
const [dates, setDates] = useState({ from: todayStr, to: todayStr });
const loadDashboardData = async () => {
const loadCashierLogs = useCallback(async (userId: number) => {
setLoadingLogs(true);
try {
const res = await api.get(`/reports/audit/user/${userId}`);
setSelectedCashierLogs(res.data);
} catch {
console.error("Error cargando logs");
} finally {
setLoadingLogs(false);
}
}, []);
const loadDashboardData = useCallback(async () => {
if (new Date(dates.from) > new Date(dates.to)) return;
try {
@@ -54,7 +71,7 @@ export default function Dashboard() {
const stats = await dashboardService.getStats(dates.from, dates.to);
setData(stats);
const usersRes = await api.get('/users');
setCashiers(usersRes.data.filter((u: any) => u.role === 'Cajero'));
setCashiers(usersRes.data.filter((u: CashierStat) => u.role === 'Cajero'));
}
} else {
const res = await api.get('/reports/cashier', {
@@ -63,7 +80,7 @@ export default function Dashboard() {
setData(res.data);
}
const params: any = {};
const params: Record<string, unknown> = {};
if (selectedCashier) params.userId = selectedCashier.id;
const recentRes = await api.get('/listings', { params });
setRecentTransactions(recentRes.data.slice(0, 5));
@@ -73,23 +90,11 @@ export default function Dashboard() {
} finally {
setLoading(false);
}
};
const loadCashierLogs = async (userId: number) => {
setLoadingLogs(true);
try {
const res = await api.get(`/reports/audit/user/${userId}`);
setSelectedCashierLogs(res.data);
} catch (e) {
console.error("Error cargando logs");
} finally {
setLoadingLogs(false);
}
};
}, [dates, role, selectedCashier, loadCashierLogs]);
useEffect(() => {
loadDashboardData();
}, [dates, role, selectedCashier]);
}, [loadDashboardData]);
const setQuickRange = (days: number) => {
const now = new Date();
@@ -101,7 +106,7 @@ export default function Dashboard() {
const handleExport = async () => {
try {
await reportService.exportCierre(dates.from, dates.to, selectedCashier?.id);
} catch (e) {
} catch {
alert("Error al generar el reporte");
}
};
@@ -203,7 +208,7 @@ export default function Dashboard() {
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#F3F4F6" />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 11 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 11 }} />
<Tooltip formatter={(val: any) => [`$${Number(val).toLocaleString()}`, "Ventas"]} contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }} />
<Tooltip formatter={(val: number | undefined) => [`$${Number(val ?? 0).toLocaleString()}`, "Ventas"]} contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }} />
<Line type="monotone" dataKey="amount" stroke="#3B82F6" strokeWidth={4} dot={{ r: 4, fill: '#3B82F6', strokeWidth: 2, stroke: '#fff' }} activeDot={{ r: 6, strokeWidth: 0 }} />
</LineChart>
</ResponsiveContainer>
@@ -341,7 +346,16 @@ export default function Dashboard() {
);
}
function KpiCard({ title, value, trend, icon, color, progress }: any) {
interface KpiCardProps {
title: string;
value: string | number;
trend: string;
icon: React.ReactNode;
color: string;
progress?: number;
}
function KpiCard({ title, value, trend, icon, color, progress }: KpiCardProps) {
return (
<div className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-center mb-4">

View File

@@ -1,16 +1,58 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import api from '../../services/api';
import type { Listing, ListingDetail } from '../../types/Listing';
import { useSearchParams } from 'react-router-dom';
import ListingDetailModal from '../../components/Listings/ListingDetailModal';
import { listingService } from '../../services/listingService';
import { Search, ExternalLink, Calendar, Tag, User as UserIcon } from 'lucide-react';
import {
Search, ExternalLink, Calendar,
X, Monitor, Globe, Download
} from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
import clsx from 'clsx';
export default function ListingExplorer() {
const [listings, setListings] = useState<any[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [listings, setListings] = useState<Listing[]>([]);
const [categories, setCategories] = useState<FlatCategory[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [selectedDetail, setSelectedDetail] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDetail, setSelectedDetail] = useState<ListingDetail | null>(null);
// ESTADO DE FILTROS
const [filters, setFilters] = useState({
query: searchParams.get('q') || "",
categoryId: "",
origin: "All",
status: "",
from: "",
to: ""
});
const loadInitialData = async () => {
const res = await api.get('/categories');
setCategories(processCategories(res.data));
};
const loadListings = useCallback(async () => {
setLoading(true);
try {
const res = await api.post('/listings/search', {
query: filters.query,
categoryId: filters.categoryId ? parseInt(filters.categoryId) : null,
origin: filters.origin,
status: filters.status,
from: filters.from || null,
to: filters.to || null
});
setListings(res.data);
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { loadInitialData(); }, []);
useEffect(() => { loadListings(); }, [loadListings]);
const handleOpenDetail = async (id: number) => {
const detail = await listingService.getById(id);
@@ -18,152 +60,201 @@ export default function ListingExplorer() {
setIsModalOpen(true);
};
const loadListings = async () => {
setLoading(true);
try {
// Usamos el endpoint de búsqueda que ya tiene el backend
const res = await api.get('/listings', {
params: { q: query }
});
let data = res.data;
if (statusFilter) {
data = data.filter((l: any) => l.status === statusFilter);
}
setListings(data);
} catch (error) {
console.error("Error cargando avisos:", error);
} finally {
setLoading(false);
}
const clearFilters = () => {
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: "", to: "" });
setSearchParams({});
};
useEffect(() => {
loadListings();
}, [statusFilter]);
return (
<div className="space-y-6">
<div className="flex justify-between items-center text-gray-800">
{/* HEADER */}
<div className="flex justify-between items-end">
<div>
<h2 className="text-2xl font-bold">Explorador de Avisos</h2>
<p className="text-sm text-gray-500">Gestión y búsqueda histórica de publicaciones</p>
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight">Explorador Maestro</h2>
<p className="text-sm text-slate-500">Auditoría y trazabilidad de publicaciones impresas y digitales</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-slate-800 text-white rounded-xl text-xs font-bold hover:bg-black transition-all">
<Download size={14} /> Exportar CSV
</button>
</div>
{/* BARRA DE FILTROS AVANZADA */}
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-xl shadow-slate-200/50 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
{/* Buscador Principal */}
<div className="md:col-span-4 relative">
<Search className="absolute left-3 top-3 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar por Título, DNI o Cliente..."
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 transition-all font-bold text-sm"
value={filters.query}
onChange={(e) => setFilters({ ...filters, query: e.target.value })}
/>
</div>
{/* Selector de Rubro */}
<div className="md:col-span-3">
<select
className="w-full p-2.5 bg-slate-50 border-2 border-slate-100 rounded-xl font-bold text-sm outline-none focus:border-blue-500 appearance-none"
value={filters.categoryId}
onChange={(e) => setFilters({ ...filters, categoryId: e.target.value })}
>
<option value="">TODOS LOS RUBROS</option>
{categories.map(c => (
<option key={c.id} value={c.id} style={{ paddingLeft: `${c.level * 10}px` }}>
{'\u00A0'.repeat(c.level * 2)} {c.name}
</option>
))}
</select>
</div>
{/* Rango de Fechas */}
<div className="md:col-span-3 flex items-center gap-2">
<input
type="date"
className="flex-1 p-2 bg-slate-50 border-2 border-slate-100 rounded-xl text-xs font-bold"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
/>
<span className="text-slate-300">-</span>
<input
type="date"
className="flex-1 p-2 bg-slate-50 border-2 border-slate-100 rounded-xl text-xs font-bold"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
/>
</div>
{/* Botón Limpiar */}
<div className="md:col-span-2">
<button
onClick={clearFilters}
className="w-full py-2.5 bg-slate-100 text-slate-500 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-rose-50 hover:text-rose-500 transition-all flex items-center justify-center gap-2"
>
<X size={14} /> Limpiar
</button>
</div>
</div>
{/* Filtros Secundarios */}
<div className="flex gap-4 pt-4 border-t border-slate-50">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Origen:</span>
<div className="flex bg-slate-100 p-1 rounded-lg">
{['All', 'Mostrador', 'Web'].map(o => (
<button
key={o}
onClick={() => setFilters({ ...filters, origin: o })}
className={clsx(
"px-3 py-1 rounded-md text-[10px] font-black uppercase transition-all",
filters.origin === o ? "bg-white text-blue-600 shadow-sm" : "text-slate-400 hover:text-slate-600"
)}
>
{o === 'All' ? 'Todos' : o}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 border-l pl-4 border-slate-100">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Estado:</span>
<select
className="bg-transparent text-[10px] font-black text-slate-600 outline-none uppercase cursor-pointer"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">TODOS</option>
<option value="Published">PUBLICADOS</option>
<option value="Pending">PENDIENTES</option>
<option value="Rejected">RECHAZADOS</option>
</select>
</div>
</div>
</div>
{/* BARRA DE HERRAMIENTAS */}
<div className="bg-white p-4 rounded-xl border shadow-sm flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400" size={18} />
<input
type="text"
placeholder="Buscar por título, contenido o ID..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadListings()}
/>
</div>
<div className="flex gap-2">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-4 py-2 text-sm font-medium text-gray-600 outline-none"
>
<option value="">Todos los estados</option>
<option value="Published">Publicados</option>
<option value="Pending">Pendientes</option>
<option value="Rejected">Rechazados</option>
<option value="Draft">Borradores</option>
</select>
<button
onClick={loadListings}
className="bg-gray-900 text-white px-6 py-2 rounded-lg font-bold hover:bg-gray-800 transition shadow-md"
>
BUSCAR
</button>
</div>
</div>
{/* RESULTADOS */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
{/* TABLA DE RESULTADOS REFINADA */}
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 text-gray-400 text-xs uppercase font-bold tracking-wider">
<thead className="bg-slate-50 text-slate-400 text-[9px] uppercase font-black tracking-[0.2em]">
<tr>
<th className="p-4 border-b">ID</th>
<th className="p-4 border-b">Aviso / Título</th>
<th className="p-4 border-b">Ingreso (Aviso)</th>
<th className="p-4 border-b">Valor Producto</th>
<th className="p-4 border-b">Creado</th>
<th className="p-4 border-b">Vía</th>
<th className="p-4 border-b text-center">Estado</th>
<th className="p-4 border-b text-right">Detalles</th>
<th className="p-5 border-b border-slate-100 text-center">Índice</th>
<th className="p-5 border-b border-slate-100">Aviso / Identificación</th>
<th className="p-5 border-b border-slate-100">Categorización</th>
<th className="p-5 border-b border-slate-100 text-right">Recaudación</th>
<th className="p-5 border-b border-slate-100 text-center">Canal</th>
<th className="p-5 border-b border-slate-100 text-center">Estado</th>
<th className="p-5 border-b border-slate-100"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
<tbody className="divide-y divide-slate-50">
{loading ? (
<tr><td colSpan={7} className="p-10 text-center text-gray-400">Buscando en la base de datos...</td></tr>
<tr><td colSpan={7} className="p-20 text-center text-slate-400 font-bold animate-pulse">Buscando registros...</td></tr>
) : listings.length === 0 ? (
<tr><td colSpan={7} className="p-10 text-center text-gray-400 italic">No se encontraron avisos con esos criterios.</td></tr>
) : (
listings.map((l) => (
<tr key={l.id} className="hover:bg-gray-50 transition-colors group">
<td className="p-4 font-mono text-gray-400">#{l.id}</td>
<td className="p-4">
<div className="font-bold text-gray-800 group-hover:text-blue-600 transition-colors">{l.title}</div>
<div className="text-xs text-gray-400 flex items-center gap-1 mt-0.5">
<Tag size={12} /> {l.categoryName || 'Sin Rubro'}
</div>
</td>
<td className="p-4">
<div className="font-bold text-green-700">
${l.adFee?.toLocaleString() ?? "0"}
</div>
</td>
<td className="p-4">
<div className="text-gray-500 text-xs">${l.price?.toLocaleString()}</div>
</td>
<td className="p-4">
<div className="text-gray-600 flex items-center gap-1.5">
<Calendar size={14} className="text-gray-300" />
{new Date(l.createdAt).toLocaleDateString()}
</div>
</td>
<td className="p-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-black uppercase ${l.userId === null ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'
}`}>
<UserIcon size={10} />
{l.userId === null ? 'Web' : 'Mostrador'}
<tr><td colSpan={7} className="p-20 text-center text-slate-400 italic">No se encontraron avisos para estos criterios.</td></tr>
) : listings.map((l, index) => (
<tr key={l.id} className="hover:bg-blue-50/30 transition-all group">
<td className="p-5 text-center font-mono text-[10px] text-slate-300 font-bold">
{(index + 1).toString().padStart(3, '0')}
</td>
<td className="p-5">
<div className="flex flex-col">
<span className="font-black text-slate-800 uppercase text-xs tracking-tight group-hover:text-blue-600 transition-colors">
{l.title}
</span>
</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase border ${l.status === 'Published' ? 'bg-green-50 text-green-700 border-green-200' :
l.status === 'Pending' ? 'bg-yellow-50 text-yellow-700 border-yellow-200' :
'bg-gray-50 text-gray-600 border-gray-200'
}`}>
{l.status}
</span>
</td>
<td className="p-4 text-right">
<button
onClick={() => handleOpenDetail(l.id)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
title="Ver Detalles"
>
<ExternalLink size={18} />
</button>
</td>
</tr>
))
)}
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] font-bold text-slate-400">ID_#{l.id}</span>
<div className="w-1 h-1 bg-slate-200 rounded-full"></div>
<span className="text-[9px] font-bold text-slate-400 flex items-center gap-1">
<Calendar size={10} /> {new Date(l.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</td>
<td className="p-5">
<span className="px-2 py-1 bg-slate-100 rounded-lg text-[9px] font-black text-slate-500 uppercase border border-slate-200">
{l.categoryName}
</span>
</td>
<td className="p-5 text-right font-mono font-black text-slate-900 text-sm">
$ {l.adFee?.toLocaleString()}
</td>
<td className="p-5 text-center">
<div className={clsx(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[9px] font-black uppercase",
l.origin === 'Web' ? "bg-blue-50 text-blue-600" : "bg-emerald-50 text-emerald-600"
)}>
{l.origin === 'Web' ? <Globe size={10} /> : <Monitor size={10} />}
{l.origin}
</div>
</td>
<td className="p-5 text-center">
<span className={clsx(
"px-2 py-1 rounded-full text-[9px] font-black uppercase border",
l.status === 'Published' ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
l.status === 'Pending' ? "bg-amber-50 text-amber-700 border-amber-200" :
"bg-slate-50 text-slate-500 border-slate-200"
)}>
{l.status}
</span>
</td>
<td className="p-5 text-right">
<button
onClick={() => handleOpenDetail(l.id)}
className="p-2 text-slate-300 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
>
<ExternalLink size={18} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<ListingDetailModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}

View File

@@ -17,7 +17,7 @@ export default function Login() {
const token = await authService.login(username, password);
setToken(token);
navigate('/');
} catch (err) {
} catch {
setError('Credenciales inválidas');
}
};

View File

@@ -28,7 +28,9 @@ export default function ModerationPage() {
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
const res = await api.get('/listings/pending');
setListings(res.data);
} catch (error) { console.error(error); }
} catch {
alert("Error obteniendo los avisos pendientes");
}
finally { setLoading(false); }
};
@@ -38,7 +40,9 @@ export default function ModerationPage() {
headers: { 'Content-Type': 'application/json' }
});
setListings(listings.filter(l => l.id !== id));
} catch (e) { alert("Error al procesar el aviso"); }
} catch {
alert("Error al procesar el aviso");
}
};
if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;

View File

@@ -13,18 +13,17 @@ interface PricingConfig {
frameSurcharge: number;
}
// Configuración por defecto
const defaultConfig: PricingConfig = {
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
};
export default function PricingManager() {
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [selectedCat, setSelectedCat] = useState<number | null>(null);
// Configuración por defecto
const defaultConfig: PricingConfig = {
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
};
const [config, setConfig] = useState<PricingConfig>(defaultConfig);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -37,14 +36,12 @@ export default function PricingManager() {
useEffect(() => {
if (selectedCat) {
setLoading(true);
// Cargar config existente
api.get(`/pricing/${selectedCat}`)
.then(res => {
if (res.data) setConfig(res.data);
else setConfig(defaultConfig); // Reset si es nuevo
})
.finally(() => setLoading(false));
});
}
}, [selectedCat]);
@@ -54,7 +51,7 @@ export default function PricingManager() {
try {
await api.post('/pricing', { ...config, categoryId: selectedCat });
alert('Configuración guardada correctamente.');
} catch (e) {
} catch {
alert('Error al guardar.');
} finally {
setSaving(false);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import api from '../../services/api';
import Modal from '../../components/Modal';
import { Percent, Trash2, Plus, Edit, CalendarClock } from 'lucide-react';
@@ -33,18 +33,21 @@ export default function PromotionsManager() {
{ val: 4, label: 'Jue' }, { val: 5, label: 'Vie' }, { val: 6, label: 'Sáb' }, { val: 0, label: 'Dom' }
];
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
const loadData = useCallback(async () => {
const [promosRes, catRes] = await Promise.all([
api.get('/pricing/promotions'),
api.get('/categories')
]);
setPromotions(promosRes.data);
setCategories(catRes.data);
};
}, []);
useEffect(() => {
const init = async () => {
await loadData();
};
init();
}, [loadData]);
const handleEdit = (promo: Promotion) => {
setEditingId(promo.id);
@@ -84,7 +87,7 @@ export default function PromotionsManager() {
}
setIsModalOpen(false);
loadData();
} catch (e) {
} catch {
alert("Error al guardar");
}
};

View File

@@ -14,7 +14,6 @@ export default function SalesByCategory() {
}, []);
const loadData = async () => {
setLoading(true);
try {
const res = await reportService.getSalesByCategory();
setData(res);
@@ -25,6 +24,12 @@ export default function SalesByCategory() {
}
};
if (loading) return (
<div className="flex items-center justify-center h-64 text-slate-400 font-bold uppercase tracking-widest text-xs">
Cargando analíticas...
</div>
);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
// @ts-ignore
import { type User } from '../../types/User';
import type { User } from '../../types/User';
import { userService, type CreateUserDto } from '../../services/userService';
import Modal from '../../components/Modal';
import { Plus, Edit, Trash2, Shield, User as UserIcon } from 'lucide-react';
@@ -40,7 +39,6 @@ export default function UserManager() {
setIsModalOpen(true);
};
// @ts-ignore
const handleEdit = (user: User) => {
setEditingId(user.id);
setFormData({
@@ -57,7 +55,7 @@ export default function UserManager() {
try {
await userService.delete(id);
loadUsers();
} catch (error) {
} catch {
alert('Error eliminando');
}
};
@@ -86,7 +84,7 @@ export default function UserManager() {
}
setIsModalOpen(false);
loadUsers();
} catch (error) {
} catch {
alert('Error procesando usuario');
}
};

View File

@@ -1,3 +1,4 @@
// src/services/clientService.ts
import api from './api';
export const clientService = {
@@ -5,8 +6,11 @@ export const clientService = {
const res = await api.get('/clients');
return res.data;
},
getHistory: async (id: number) => {
const res = await api.get(`/clients/${id}/history`);
getSummary: async (id: number) => {
const res = await api.get(`/clients/${id}/summary`);
return res.data;
},
update: async (id: number, clientData: any) => {
await api.put(`/clients/${id}`, clientData);
}
};

View File

@@ -7,6 +7,10 @@ export interface DashboardData {
paperOccupation: number;
weeklyTrend: { day: string; amount: number }[];
channelMix: { name: string; value: number }[];
// Campos para vista de cajero o detalle
myRevenue?: number;
myAdsCount?: number;
myPendingAds?: number;
}
export const dashboardService = {

View File

@@ -1,12 +1,16 @@
// src/services/listingService.ts
import api from './api';
import type { ListingDetail } from '../types/Listing';
export const listingService = {
getPendingCount: async (): Promise<number> => {
const response = await api.get<number>('/listings/pending/count');
return response.data;
},
getById: async (id: number) => {
const res = await api.get(`/listings/${id}`);
getById: async (id: number): Promise<ListingDetail> => {
const res = await api.get<ListingDetail>(`/listings/${id}`);
return res.data;
},
updateStatus: async (id: number, status: string): Promise<void> => {
await api.put(`/listings/${id}/status`, JSON.stringify(status), {
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -12,7 +12,7 @@ interface AuthState {
const parseJwt = (token: string) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
} catch {
return null;
}
};

View File

@@ -0,0 +1,26 @@
export interface CashierStat {
id: number;
username: string;
role: string;
}
export interface RecentTransaction {
id: number;
createdAt: string;
title: string;
categoryName: string;
adFee: number;
status: string;
}
export interface AuditLogItem {
id: number;
action: string;
createdAt: string;
details: string;
}
export interface TrendItem {
day: string;
amount: number;
}

View File

@@ -0,0 +1,55 @@
// src/types/Listing.ts
export interface Listing {
id: number;
categoryId: number;
operationId: number;
title: string;
description: string;
price: number;
adFee: number;
currency: string;
createdAt: string;
status: string;
userId: number | null;
clientId: number | null;
categoryName?: string;
mainImageUrl?: string;
viewCount: number;
origin: string;
overlayStatus?: string | null;
}
export interface ListingAttribute {
id: number;
listingId: number;
attributeDefinitionId: number;
value: string;
attributeName: string;
}
export interface ListingImage {
id: number;
listingId: number;
url: string;
isMainInfo: boolean;
displayOrder: number;
}
export interface Payment {
id: number;
listingId: number;
amount: number;
paymentMethod: string;
cardPlan?: string;
surcharge: number;
paymentDate: string;
status: string;
}
export interface ListingDetail {
listing: Listing;
attributes: ListingAttribute[];
images: ListingImage[];
payments: Payment[];
}

View File

@@ -20,7 +20,6 @@ export const buildTree = (categories: Category[]): CategoryNode[] => {
// 1. Inicializar nodos
categories.forEach(cat => {
// @ts-ignore
map.set(cat.id, { ...cat, children: [] });
});