Feat: Cambios Varios 2
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -12,7 +12,7 @@ interface AuthState {
|
||||
const parseJwt = (token: string) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
26
frontend/admin-panel/src/types/Dashboard.ts
Normal file
26
frontend/admin-panel/src/types/Dashboard.ts
Normal 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;
|
||||
}
|
||||
55
frontend/admin-panel/src/types/Listing.ts
Normal file
55
frontend/admin-panel/src/types/Listing.ts
Normal 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[];
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export const buildTree = (categories: Category[]): CategoryNode[] => {
|
||||
|
||||
// 1. Inicializar nodos
|
||||
categories.forEach(cat => {
|
||||
// @ts-ignore
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user