diff --git a/.env b/.env new file mode 100644 index 0000000..5da7273 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# Archivo SIG-CM/.env +ConnectionStrings__DefaultConnection="Server=TECNICA3;Database=SIGCM;;User Id=sigcmApi;Password=@Diego550@;TrustServerCertificate=True" +Jwt__Key="badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2" +Jwt__Issuer="SIGCMApi" +Jwt__Audience="SIGCMAdmin" + +# MercadoPago Configuration +MP_ACCESS_TOKEN="TEST-71539281-2291-443b-873b-eb8647021589-122610-86ec037f07067d55d7b5b31cb9c1069b-1375354" +MP_PUBLIC_KEY="TEST-71539281-2291-443b-873b-eb8647021589" +MP_SUCCESS_URL="http://localhost:5173/publicar/exito" +MP_FAILURE_URL="http://localhost:5173/publicar/error" +MP_NOTIFICATION_URL="https://your-webhook-proxy.com/api/payments/webhook" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f3f1f85..98caf82 100644 --- a/.gitignore +++ b/.gitignore @@ -22,11 +22,11 @@ yarn-error.log* # Archivos de variables de entorno locales (¡MUY IMPORTANTE!) # Contienen secretos como API Keys y contraseñas. NUNCA deben subirse. -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +#.env +#.env.local +#.env.development.local +#.env.test.local +#.env.production.local # ---------------------------------------------------------------------------- # ## Backend: .NET / C# (Carpeta ChatbotApi/) ## diff --git a/frontend/admin-panel/.env b/frontend/admin-panel/.env new file mode 100644 index 0000000..7db865a --- /dev/null +++ b/frontend/admin-panel/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5176/api +VITE_BASE_URL=http://localhost:5176 \ No newline at end of file diff --git a/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx b/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx index 042304d..4219e0d 100644 --- a/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx +++ b/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx @@ -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) {

Galería de Fotos

{images.length > 0 ? (
- {images.map((img: any) => ( + {images.map((img: { id: number; url: string }) => (

Ficha Técnica

- {attributes.map((attr: any) => ( + {attributes.map((attr: { id: number; attributeName: string; value: string }) => (
{attr.attributeName} {attr.value} diff --git a/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx b/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx index fb13f36..3ec2260 100644 --- a/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx +++ b/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx @@ -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([]); + const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { diff --git a/frontend/admin-panel/src/pages/Categories/CategoryManager.tsx b/frontend/admin-panel/src/pages/Categories/CategoryManager.tsx index 3e4a3cd..7436c5b 100644 --- a/frontend/admin-panel/src/pages/Categories/CategoryManager.tsx +++ b/frontend/admin-panel/src/pages/Categories/CategoryManager.tsx @@ -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(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() {
- - {/* Botón Mover Contenido (Nuevo) */} - @@ -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 (
@@ -438,7 +438,6 @@ export default function CategoryManager() { {/* --- MODALES --- */} - {/* Main Modal (Create/Edit) */} setIsModalOpen(false)} @@ -494,7 +493,6 @@ export default function CategoryManager() { - {/* Matrix Operations Modal */} setIsOpsModalOpen(false)} @@ -525,14 +523,12 @@ export default function CategoryManager() {
- {/* Attributes Configuration Modal */} setIsAttrModalOpen(false)} title={`Atributos de: ${configuringCategory?.name}`} >
- {/* List Existing Attributes */}

Atributos Actuales

{attributes.length === 0 ? ( @@ -555,7 +551,6 @@ export default function CategoryManager() { )}
- {/* Add New Attribute Form */}

Agregar Atributo

@@ -595,7 +590,6 @@ export default function CategoryManager() {
- {/* Merge Modal */} setIsMergeModalOpen(false)} title="Fusionar Categorías">

@@ -631,7 +625,6 @@ export default function CategoryManager() {

- {/* MOVE CONTENT MODAL */} setIsMoveContentModalOpen(false)} title="Mover Avisos (Vaciar)">
@@ -650,8 +643,8 @@ export default function CategoryManager() { {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 => ( @@ -659,10 +652,6 @@ export default function CategoryManager() {
- {targetMergeId === 0 && ( -

Debe seleccionar un destino válido.

- )} -
-
); } \ No newline at end of file diff --git a/frontend/admin-panel/src/pages/Categories/OperationManager.tsx b/frontend/admin-panel/src/pages/Categories/OperationManager.tsx index 13058e4..eee89a1 100644 --- a/frontend/admin-panel/src/pages/Categories/OperationManager.tsx +++ b/frontend/admin-panel/src/pages/Categories/OperationManager.tsx @@ -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([]); 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 ( diff --git a/frontend/admin-panel/src/pages/Clients/ClientManager.tsx b/frontend/admin-panel/src/pages/Clients/ClientManager.tsx index 32842b2..8a7cc88 100644 --- a/frontend/admin-panel/src/pages/Clients/ClientManager.tsx +++ b/frontend/admin-panel/src/pages/Clients/ClientManager.tsx @@ -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([]); + const navigate = useNavigate(); + const [clients, setClients] = useState([]); 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(null); + const [summary, setSummary] = useState(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 (
-
-

- - Directorio de Clientes -

-
+
+
+

+ Directorio de Clientes +

+

Gestión de datos fiscales y analítica de anunciantes

+
+
- {/* Buscador */}
setSearch(e.target.value)} />
- {/* Grid de Clientes */}
{loading ? ( -

Cargando base de datos...

+
+ Sincronizando base de datos... +
) : filteredClients.map(client => ( -
+
-
- {client.name.charAt(0)} +
+ {(client.name || "?").charAt(0).toUpperCase()}
- - ID: #{client.id} - +
-

{client.name}

-
- - {client.dniOrCuit} +

{client.name}

+
+ {client.dniOrCuit}
- - {client.email || 'Sin correo'} + {client.email || 'N/A'}
- - {client.phone || 'Sin teléfono'} + {client.phone || 'N/A'}
-
-
-
Avisos
-
{client.totalAds}
+
+
+
Avisos
+
{client.totalAds}
-
Invertido
-
${client.totalSpent?.toLocaleString() || 0}
+
Invertido
+
${client.totalSpent?.toLocaleString()}
-
))}
+ + {/* --- MODAL DE EDICIÓN / FACTURACIÓN --- */} + setShowEditModal(false)} title="Datos Fiscales y de Contacto"> + +
+
+ + setSelectedClient({ ...selectedClient!, name: e.target.value })} + /> +
+
+ + setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })} + /> +
+
+ + setSelectedClient({ ...selectedClient!, phone: e.target.value })} + /> +
+
+ + setSelectedClient({ ...selectedClient!, email: e.target.value })} + /> +
+
+ +
+ + setSelectedClient({ ...selectedClient!, address: e.target.value })} + placeholder="Calle, Nro, Localidad..." + /> +
+
+
+
+ + +
+ +
+ + {/* --- MODAL DE FICHA INTEGRAL (SUMMARY) --- */} + setShowSummaryModal(false)} + title="Perfil de Actividad" + > + {summary ? ( +
+
+
+
+ {summary.Name.charAt(0)} +
+
+

{summary.Name}

+

{summary.DniOrCuit}

+
+
+ +
+
+

Inversión Total

+

${summary.TotalInvested.toLocaleString()}

+
+
+

Avisos Activos

+

{summary.ActiveAds}

+
+
+

Avisos Totales

+

{summary.TotalAds}

+
+
+

Rubro Predilecto

+

{summary.PreferredCategory}

+
+
+ +
+
+ Última Publicación + {summary.LastAdDate ? new Date(summary.LastAdDate).toLocaleDateString() : 'NUNCA'} +
+
+ Email Registrado + {summary.Email || 'S/D'} +
+
+ Teléfono + {summary.Phone || 'S/D'} +
+
+ + +
+ ) : ( +
+
+

Analizando historial del cliente...

+
+ )} +
); } \ No newline at end of file diff --git a/frontend/admin-panel/src/pages/Dashboard.tsx b/frontend/admin-panel/src/pages/Dashboard.tsx index c7748e5..4eff90b 100644 --- a/frontend/admin-panel/src/pages/Dashboard.tsx +++ b/frontend/admin-panel/src/pages/Dashboard.tsx @@ -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(null); - const [cashiers, setCashiers] = useState([]); - const [recentTransactions, setRecentTransactions] = useState([]); + const [data, setData] = useState(null); + const [cashiers, setCashiers] = useState([]); + const [recentTransactions, setRecentTransactions] = useState([]); const [loading, setLoading] = useState(true); // --- NUEVOS ESTADOS PARA MONITOR INTEGRADO --- const [selectedCashier, setSelectedCashier] = useState<{ id: number, name: string } | null>(null); - const [selectedCashierLogs, setSelectedCashierLogs] = useState([]); + const [selectedCashierLogs, setSelectedCashierLogs] = useState([]); 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 = {}; 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() { - [`$${Number(val).toLocaleString()}`, "Ventas"]} contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }} /> + [`$${Number(val ?? 0).toLocaleString()}`, "Ventas"]} contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }} /> @@ -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 (
diff --git a/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx b/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx index da0884f..5d50cfa 100644 --- a/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx +++ b/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx @@ -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([]); + const [searchParams, setSearchParams] = useSearchParams(); + const [listings, setListings] = useState([]); + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); - const [query, setQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [selectedDetail, setSelectedDetail] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedDetail, setSelectedDetail] = useState(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 (
-
+ {/* HEADER */} +
-

Explorador de Avisos

-

Gestión y búsqueda histórica de publicaciones

+

Explorador Maestro

+

Auditoría y trazabilidad de publicaciones impresas y digitales

+
+ +
+ + {/* BARRA DE FILTROS AVANZADA */} +
+
+ + {/* Buscador Principal */} +
+ + setFilters({ ...filters, query: e.target.value })} + /> +
+ + {/* Selector de Rubro */} +
+ +
+ + {/* Rango de Fechas */} +
+ setFilters({ ...filters, from: e.target.value })} + /> + - + setFilters({ ...filters, to: e.target.value })} + /> +
+ + {/* Botón Limpiar */} +
+ +
+
+ + {/* Filtros Secundarios */} +
+
+ Origen: +
+ {['All', 'Mostrador', 'Web'].map(o => ( + + ))} +
+
+ +
+ Estado: + +
- {/* BARRA DE HERRAMIENTAS */} -
-
- - setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && loadListings()} - /> -
- -
- - - -
-
- - {/* RESULTADOS */} -
+ {/* TABLA DE RESULTADOS REFINADA */} +
- + - - - - - - - - + + + + + + + - + {loading ? ( - + ) : listings.length === 0 ? ( - - ) : ( - listings.map((l) => ( - - - - - - - + ) : listings.map((l, index) => ( + + + - - - - )) - )} +
+ ID_#{l.id} +
+ + {new Date(l.createdAt).toLocaleDateString()} + +
+ + + + + + + + + ))}
IDAviso / TítuloIngreso (Aviso)Valor ProductoCreadoVíaEstadoDetallesÍndiceAviso / IdentificaciónCategorizaciónRecaudaciónCanalEstado
Buscando en la base de datos...
Buscando registros...
No se encontraron avisos con esos criterios.
#{l.id} -
{l.title}
-
- {l.categoryName || 'Sin Rubro'} -
-
-
- ${l.adFee?.toLocaleString() ?? "0"} -
-
-
${l.price?.toLocaleString()}
-
-
- - {new Date(l.createdAt).toLocaleDateString()} -
-
- - - {l.userId === null ? 'Web' : 'Mostrador'} +
No se encontraron avisos para estos criterios.
+ {(index + 1).toString().padStart(3, '0')} + +
+ + {l.title} -
- - {l.status} - - - -
+ + {l.categoryName} + + + $ {l.adFee?.toLocaleString()} + +
+ {l.origin === 'Web' ? : } + {l.origin} +
+
+ + {l.status} + + + +
+ setIsModalOpen(false)} diff --git a/frontend/admin-panel/src/pages/Login.tsx b/frontend/admin-panel/src/pages/Login.tsx index f467765..0dc0249 100644 --- a/frontend/admin-panel/src/pages/Login.tsx +++ b/frontend/admin-panel/src/pages/Login.tsx @@ -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'); } }; diff --git a/frontend/admin-panel/src/pages/Moderation/ModerationPage.tsx b/frontend/admin-panel/src/pages/Moderation/ModerationPage.tsx index 01aad52..5a43ead 100644 --- a/frontend/admin-panel/src/pages/Moderation/ModerationPage.tsx +++ b/frontend/admin-panel/src/pages/Moderation/ModerationPage.tsx @@ -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
Cargando avisos para revisar...
; diff --git a/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx b/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx index ff1919c..5b58339 100644 --- a/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx +++ b/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx @@ -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([]); const [selectedCat, setSelectedCat] = useState(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(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); diff --git a/frontend/admin-panel/src/pages/Pricing/PromotionsManager.tsx b/frontend/admin-panel/src/pages/Pricing/PromotionsManager.tsx index f2684d1..6e94766 100644 --- a/frontend/admin-panel/src/pages/Pricing/PromotionsManager.tsx +++ b/frontend/admin-panel/src/pages/Pricing/PromotionsManager.tsx @@ -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"); } }; diff --git a/frontend/admin-panel/src/pages/Reports/SalesByCategory.tsx b/frontend/admin-panel/src/pages/Reports/SalesByCategory.tsx index 2a4174d..ce4625c 100644 --- a/frontend/admin-panel/src/pages/Reports/SalesByCategory.tsx +++ b/frontend/admin-panel/src/pages/Reports/SalesByCategory.tsx @@ -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 ( +
+ Cargando analíticas... +
+ ); + return (
diff --git a/frontend/admin-panel/src/pages/Users/UserManager.tsx b/frontend/admin-panel/src/pages/Users/UserManager.tsx index 0554596..837f889 100644 --- a/frontend/admin-panel/src/pages/Users/UserManager.tsx +++ b/frontend/admin-panel/src/pages/Users/UserManager.tsx @@ -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'); } }; diff --git a/frontend/admin-panel/src/services/clientService.ts b/frontend/admin-panel/src/services/clientService.ts index 8cd5e50..0093a19 100644 --- a/frontend/admin-panel/src/services/clientService.ts +++ b/frontend/admin-panel/src/services/clientService.ts @@ -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); } }; \ No newline at end of file diff --git a/frontend/admin-panel/src/services/dashboardService.ts b/frontend/admin-panel/src/services/dashboardService.ts index f56d6b6..ec86035 100644 --- a/frontend/admin-panel/src/services/dashboardService.ts +++ b/frontend/admin-panel/src/services/dashboardService.ts @@ -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 = { diff --git a/frontend/admin-panel/src/services/listingService.ts b/frontend/admin-panel/src/services/listingService.ts index 06083b9..0e5c133 100644 --- a/frontend/admin-panel/src/services/listingService.ts +++ b/frontend/admin-panel/src/services/listingService.ts @@ -1,12 +1,16 @@ +// src/services/listingService.ts import api from './api'; +import type { ListingDetail } from '../types/Listing'; export const listingService = { - getPendingCount: async (): Promise => { - const response = await api.get('/listings/pending/count'); - return response.data; - }, - getById: async (id: number) => { - const res = await api.get(`/listings/${id}`); + getById: async (id: number): Promise => { + const res = await api.get(`/listings/${id}`); return res.data; }, + + updateStatus: async (id: number, status: string): Promise => { + await api.put(`/listings/${id}/status`, JSON.stringify(status), { + headers: { 'Content-Type': 'application/json' } + }); + } }; \ No newline at end of file diff --git a/frontend/admin-panel/src/store/authStore.ts b/frontend/admin-panel/src/store/authStore.ts index 69f4608..24f0715 100644 --- a/frontend/admin-panel/src/store/authStore.ts +++ b/frontend/admin-panel/src/store/authStore.ts @@ -12,7 +12,7 @@ interface AuthState { const parseJwt = (token: string) => { try { return JSON.parse(atob(token.split('.')[1])); - } catch (e) { + } catch { return null; } }; diff --git a/frontend/admin-panel/src/types/Dashboard.ts b/frontend/admin-panel/src/types/Dashboard.ts new file mode 100644 index 0000000..82bf6ad --- /dev/null +++ b/frontend/admin-panel/src/types/Dashboard.ts @@ -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; +} diff --git a/frontend/admin-panel/src/types/Listing.ts b/frontend/admin-panel/src/types/Listing.ts new file mode 100644 index 0000000..09fa0dc --- /dev/null +++ b/frontend/admin-panel/src/types/Listing.ts @@ -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[]; +} \ No newline at end of file diff --git a/frontend/admin-panel/src/utils/categoryTreeUtils.ts b/frontend/admin-panel/src/utils/categoryTreeUtils.ts index 1f58ba8..5adf7db 100644 --- a/frontend/admin-panel/src/utils/categoryTreeUtils.ts +++ b/frontend/admin-panel/src/utils/categoryTreeUtils.ts @@ -20,7 +20,6 @@ export const buildTree = (categories: Category[]): CategoryNode[] => { // 1. Inicializar nodos categories.forEach(cat => { - // @ts-ignore map.set(cat.id, { ...cat, children: [] }); }); diff --git a/frontend/counter-panel/.env b/frontend/counter-panel/.env new file mode 100644 index 0000000..7db865a --- /dev/null +++ b/frontend/counter-panel/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5176/api +VITE_BASE_URL=http://localhost:5176 \ No newline at end of file diff --git a/frontend/counter-panel/package-lock.json b/frontend/counter-panel/package-lock.json index 308128b..a29c369 100644 --- a/frontend/counter-panel/package-lock.json +++ b/frontend/counter-panel/package-lock.json @@ -10,10 +10,12 @@ "dependencies": { "axios": "^1.13.2", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.11.0", + "recharts": "^3.6.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" }, @@ -1032,6 +1034,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz", + "integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1347,6 +1385,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -1663,6 +1713,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1707,6 +1820,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", @@ -2327,6 +2446,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2345,6 +2585,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2451,6 +2697,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2700,6 +2956,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2840,6 +3102,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3032,6 +3321,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3059,6 +3358,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3546,6 +3854,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3772,6 +4095,36 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3820,6 +4173,57 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3984,6 +4388,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4014,6 +4424,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4113,6 +4529,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", diff --git a/frontend/counter-panel/package.json b/frontend/counter-panel/package.json index a680a32..7ca2570 100644 --- a/frontend/counter-panel/package.json +++ b/frontend/counter-panel/package.json @@ -12,10 +12,12 @@ "dependencies": { "axios": "^1.13.2", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.11.0", + "recharts": "^3.6.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" }, diff --git a/frontend/counter-panel/src/App.tsx b/frontend/counter-panel/src/App.tsx index b119470..088dfcb 100644 --- a/frontend/counter-panel/src/App.tsx +++ b/frontend/counter-panel/src/App.tsx @@ -2,7 +2,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import CounterLayout from './layouts/CounterLayout'; import FastEntryPage from './pages/FastEntryPage'; import CashRegisterPage from './pages/CashRegisterPage'; +import AdminDashboard from './pages/AdminDashboard'; +import AdvancedAnalytics from './pages/AdvancedAnalytics'; import LoginPage from './pages/LoginPage'; +import { ToastProvider } from './context/ToastContext'; +import HistoryPage from './pages/HistoryPage'; +import TreasuryPage from './pages/TreasuryPage'; // Componente simple de protección const PrivateRoute = ({ children }: { children: React.ReactNode }) => { @@ -12,16 +17,23 @@ const PrivateRoute = ({ children }: { children: React.ReactNode }) => { function App() { return ( - - - } /> + + + + } /> - }> - } /> - } /> - - - + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/frontend/counter-panel/src/components/CashClosingModal.tsx b/frontend/counter-panel/src/components/CashClosingModal.tsx new file mode 100644 index 0000000..6902eb7 --- /dev/null +++ b/frontend/counter-panel/src/components/CashClosingModal.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { Banknote, CreditCard, ArrowRightLeft, CheckCircle2, X, AlertCircle, RefreshCw, Printer } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import clsx from 'clsx'; +import api from '../services/api'; + +interface CashClosingModalProps { + onClose: () => void; + onComplete: () => void; +} + +export default function CashClosingModal({ onClose, onComplete }: CashClosingModalProps) { + const [summary, setSummary] = useState(null); + const [loadingSummary, setLoadingSummary] = useState(true); + const [notes, setNotes] = useState(''); + const [isClosing, setIsClosing] = useState(false); + const [done, setDone] = useState(false); + const [closedSessionId, setClosedSessionId] = useState(null); + + // 1. Al abrir el modal, traemos lo que el sistema dice que debería haber + useEffect(() => { + const fetchSummary = async () => { + try { + const res = await api.get('/cashsessions/summary'); + setSummary(res.data); + } catch (error) { + console.error(error); + } finally { + setLoadingSummary(false); + } + }; + fetchSummary(); + }, []); + + const handleFinalClose = async () => { + setIsClosing(true); + try { + const res = await api.post('/cashsessions/close', { + declaredCash: summary.cashSales + summary.openingBalance, + declaredDebit: summary.cardSales, + declaredCredit: 0, + declaredTransfer: summary.transferSales, + notes: notes || "Cierre confirmado por cajero" + }); + + setClosedSessionId(res.data.sessionId); + setDone(true); + } catch (error) { + console.error(error); + alert('Error al cerrar caja'); + } finally { + setIsClosing(false); + } + }; + + const handleDownloadPdf = async () => { + if (!closedSessionId) return; + try { + const res = await api.get(`/cashsessions/${closedSessionId}/pdf`, { responseType: 'blob' }); + const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `Acta_Cierre_${closedSessionId}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + alert("Error al descargar el comprobante"); + } + }; + + if (loadingSummary) return ( +
+ +
+ ); + + return ( +
+ + {/* Header */} +
+
+
+ Finalización de Turno +

Cierre de Caja

+
+ {!done && ( + + )} +
+
+ +
+ + {!done ? ( + + + {/* Visualización de Totales del Sistema */} +
+

Resumen de Valores en Caja

+
+ } /> + } isSale /> + } isSale /> + } isSale /> +
+
+ + {/* Total a Entregar */} +
+
+ Total Final a Entregar + + $ {summary.totalExpected.toLocaleString('es-AR', { minimumFractionDigits: 2 })} + +
+ +
+ + {/* Notas opcionales */} +
+ + +
F10 para Cobrar
+
+
+
+
Palabras 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}
+
Signos Especiales 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}
+
+
Vista optimizada para diario
{[1, 2, 3, 4, 5].map(i =>
i * 15 ? "bg-blue-500" : "bg-slate-800")}>
)}
+
+
+ +
+
+ +
+ + setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} /> + +
+
+
+ +
+ + setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} /> +
+ {showSuggestions && clientSuggestions.length > 0 && ( + + {clientSuggestions.map(client => ( +
handleSelectClient(client)}> +
{client.name}
{client.dniOrCuit}
+
))} -
-
- )} +
+ )} +
+
+ + setFormData({ ...formData, clientDni: e.target.value })} /> +
+
+
-
- - + {/* PANEL DERECHO */} +
+
+
Total a Cobrar
+
+ ${pricing.totalPrice.toLocaleString()} +
+
+
Tarifa Base${pricing.baseCost.toLocaleString()}
+ {pricing.extraCost > 0 &&
Recargos Texto+${pricing.extraCost.toLocaleString()}
} + {pricing.discount > 0 &&
Descuento-${pricing.discount.toLocaleString()}
}
-
- - -
-
- 0 ? "text-blue-600" : ""}>Palabras: {pricing.wordCount} - {pricing.specialCharCount > 0 && Signos: {pricing.specialCharCount}} -
- Uso de mayúsculas recomendado -
-
- -
-
- - setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} /> -
-
- -
- - setShowSuggestions(true)} - onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} - /> -
- {showSuggestions && clientSuggestions.length > 0 && ( -
- {clientSuggestions.map(client => ( -
handleSelectClient(client)}> -
-
{client.name}
-
{client.dniOrCuit}
-
-
+
+
+
+
+ {['left', 'center', 'right', 'justify'].map(align => ( + ))}
- )} -
-
- - setFormData({ ...formData, clientDni: e.target.value })} /> -
-
-
-
- - {/* PANEL DERECHO: TOTALES Y VISTA PREVIA */} -
- - {/* TOTALES (FIJO ARRIBA) */} -
-
Total a Cobrar
-
- $ - {pricing.totalPrice.toLocaleString()} -
-
-
- Tarifa Base - ${pricing.baseCost.toLocaleString()} -
- {pricing.extraCost > 0 && ( -
- Recargos por texto - +${pricing.extraCost.toLocaleString()} -
- )} - {pricing.surcharges > 0 && ( -
- Estilos visuales - +${pricing.surcharges.toLocaleString()} -
- )} - {pricing.discount > 0 && ( -
-
- Descuento Aplicado - -${pricing.discount.toLocaleString()} +
+ {['small', 'normal', 'large'].map(size => ( + + ))}
- {pricing.appliedPromotion}
- )} +
+ + +
+
+ +
+
+

Previsualización Real

+
+
+ {formData.text || "(Aviso vacío)"} +
+
+
+ +
- {/* CONTENEDOR CENTRAL SCROLLABLE (Toolbar + Preview) */} -
- - {/* TOOLBAR ESTILOS */} -
-
-
- {['left', 'center', 'right', 'justify'].map(align => ( - - ))} -
-
- {['small', 'normal', 'large'].map(size => ( - - ))} -
-
- -
- - -
-
- - {/* VISTA PREVIA (DINÁMICA AL ALTO DEL TEXTO) */} -
-
-

- Previsualización Real -

-
-
- {formData.text || "(Aviso vacío)"} -
-
-
-
- - {/* ACCIÓN PRINCIPAL (FIJO ABAJO) */} - -
-
+ {showPaymentModal && ( + setShowPaymentModal(false)} /> + )} + + ); } \ No newline at end of file diff --git a/frontend/counter-panel/src/pages/HistoryPage.tsx b/frontend/counter-panel/src/pages/HistoryPage.tsx new file mode 100644 index 0000000..62f1947 --- /dev/null +++ b/frontend/counter-panel/src/pages/HistoryPage.tsx @@ -0,0 +1,524 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '../services/api'; +import { + Search, User as UserIcon, ChevronRight, + X, FileText, Banknote, CreditCard, + Clock, Filter, Printer, + MessageSquare, ShieldAlert, CheckCircle2, + ShieldCheck +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useToast } from '../context/use-toast'; +import clsx from 'clsx'; +import ClaimModal from '../components/ClaimModal'; +import ResolveClaimModal from '../components/ResolveClaimModal'; + +export default function HistoryPage() { + const { showToast } = useToast(); + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [cajeros, setCajeros] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [showClaimModal, setShowClaimModal] = useState(false); + const [claims, setClaims] = useState([]); + const [resolvingClaim, setResolvingClaim] = useState(null); + const [activeDetailTab, setActiveDetailTab] = useState<'info' | 'claims'>('info'); + + // Filtros + const [filters, setFilters] = useState({ + from: new Date().toISOString().split('T')[0], + to: new Date().toISOString().split('T')[0], + userId: '', + query: '', + source: 'All' // 'All' | 'Web' | 'Mostrador' + }); + + const loadInitialData = async () => { + try { + const res = await api.get('/reports/cajeros'); + setCajeros(res.data); + } catch (e) { console.error(e); } + }; + + const loadHistory = useCallback(async () => { + setLoading(true); + try { + const response = await api.get('/reports/cashier-transactions', { + params: { + from: filters.from, + to: filters.to, + userId: filters.userId || null + } + }); + setItems(response.data.items || []); + } catch (error) { + console.error("Error al cargar historial:", error); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + loadInitialData(); + loadHistory(); + }, [loadHistory]); + + const loadItemClaims = async (listingId: number) => { + try { + const res = await api.get(`/claims/listing/${listingId}`); + setClaims(res.data); + } catch (e) { console.error(e); } + }; + + const handleOpenDetail = async (id: number) => { + setActiveDetailTab('info'); + try { + const res = await api.get(`/listings/${id}`); + setSelectedItem(res.data); + loadItemClaims(id); + } catch (e) { console.error(e); } + }; + + const formatLocalTime = (dateString: string) => { + let isoStr = dateString.replace(" ", "T"); + if (!isoStr.endsWith("Z")) isoStr += "Z"; + return new Date(isoStr).toLocaleTimeString('es-AR', { + hour: '2-digit', minute: '2-digit', hour12: true, + timeZone: 'America/Argentina/Buenos_Aires' + }); + }; + + const escapeHTML = (str: string) => { + return str?.replace(/[&<>"']/g, (m) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[m] || m)) || ''; + }; + + const handlePrintDuplicate = () => { + if (!selectedItem) return; + + const { listing, payments } = selectedItem; + const printWindow = window.open('', '_blank', 'width=350,height=700'); + if (!printWindow) return; + + const now = new Date(); + const totalWithSurcharges = payments.reduce((sum: number, p: any) => sum + p.amount + p.surcharge, 0); + + const paymentRows = payments.map((p: any) => { + const methodName = p.paymentMethod === 'Credit' ? p.cardPlan : p.paymentMethod; + return ` + + ${methodName} + $${p.amount.toLocaleString()} + ${p.surcharge > 0 ? `+$${p.surcharge.toLocaleString()}` : ''} + + `; + }).join(''); + + const html = ` + + +
+

DIARIO EL DÍA

+

*** DUPLICADO ***

+

Original: ${new Date(listing.createdAt).toLocaleString('es-AR')}

+

Re-impresión: ${now.toLocaleString('es-AR')}

+
+
+ AVISO ID: #${listing.id.toString().padStart(6, '0')}
+ RUBRO: ${escapeHTML(listing.categoryName)}
+ ESTADO: ${listing.status}
+
+
+ CUERPO DEL AVISO:
+

${escapeHTML(listing.description)}

+
+ + ${paymentRows} +
+
+ TOTAL: $${totalWithSurcharges.toLocaleString()} +
+ + + `; + + printWindow.document.write(html); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { printWindow.print(); printWindow.close(); }, 250); + showToast("Duplicado enviado a la cola de impresión", "success"); + }; + + return ( +
+ + {/* FILTROS */} +
+
+
+ Auditoría & Consultas +

+ + Historial de Operaciones +

+
+ +
+ +
+
+ + setFilters({ ...filters, from: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all" /> +
+
+ + setFilters({ ...filters, to: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all" /> +
+
+ +
+ {['All', 'Mostrador', 'Web'].map(s => ( + + ))} +
+
+
+ + +
+
+ +
+ + setFilters({ ...filters, query: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 pl-10 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all placeholder:text-slate-300" /> +
+
+
+
+ + {/* TABLA */} +
+
+ + + + + + + + + + + + + {items + .filter(i => (filters.source === 'All' || i.source === filters.source)) + .filter(i => i.title.toLowerCase().includes(filters.query.toLowerCase())) + .map((item) => ( + + + + + + + + ))} + + +
Operación / FechaDetalle del AvisoCajeroMonto Total
+ {/* Badge de Origen */} +
+ {item.source} +
+
#{item.id.toString().padStart(6, '0')}
+
+ + {new Date(item.date).toLocaleDateString('es-AR')} - {formatLocalTime(item.date)} +
+
+
+ {item.title} + {item.category} +
+
+ + {item.cashier || 'SISTEMA'} + + + $ + {item.amount.toLocaleString('es-AR', { minimumFractionDigits: 2 })} + + +
+
+
+ + {/* PANEL LATERAL */} + + {selectedItem && ( +
+ + {/* HEADER DEL PANEL */} +
+
+ Auditoría de Aviso +

#{selectedItem.listing.id.toString().padStart(6, '0')}

+
+ +
+ + {/* BARRA DE NAVEGACIÓN DE PESTAÑAS (TABS) */} +
+ + +
+ + {/* CUERPO DEL PANEL CON SCROLL INDEPENDIENTE */} +
+ + + {/* PESTAÑA 1: INFORMACIÓN GENERAL */} + {activeDetailTab === 'info' && ( + + {/* Status & Total */} +
+
+
+ Recaudación Total +
$ {selectedItem.listing.adFee?.toLocaleString('es-AR')}
+
+ {selectedItem.listing.status} +
+
+
+ + {/* Texto para Imprenta */} +
+

+ Cuerpo de Publicación +

+
+ {selectedItem.listing.description} +
+
+ + {/* Desglose de Pagos */} +
+

+ Medios de Pago +

+
+ {selectedItem.payments?.map((p: any, pIdx: number) => ( +
+
+
+ {p.paymentMethod === 'Cash' ? : } +
+ {p.paymentMethod} {p.cardPlan || ''} +
+ $ {p.amount.toLocaleString()} +
+ ))} +
+
+ + {/* Datos Técnicos */} +
+ + + + +
+
+ )} + + {/* PESTAÑA 2: INCIDENCIAS Y RECLAMOS */} + {activeDetailTab === 'claims' && ( + +
+

+ Historial de Reclamos +

+ Total: {claims.length} eventos +
+ + {claims.length === 0 ? ( +
+ +

Sin incidencias registradas

+
+ ) : ( +
+ {claims.map((c: any) => ( +
+ {/* ... (Contenido del card de reclamo, igual que antes) ... */} +
+ + {c.status === 'Open' ? 'Pendiente de Resolución' : 'Incidencia Resuelta'} + + {new Date(c.createdAt).toLocaleDateString()} +
+

{c.claimType}

+

"{c.description}"

+ + {c.status === 'Open' ? ( + + ) : ( +
+ {c.originalValues && ( +
+

Snapshot Pre-ajuste:

+

{c.originalValues}

+
+ )} +
+

Solución aplicada:

+

"{c.solutionDescription}"

+

Por: {c.resolvedByUsername} • {new Date(c.resolvedAt).toLocaleDateString()}

+
+
+ )} +
+ ))} +
+ )} +
+ )} +
+
+ + {/* FOOTER GLOBAL (SIEMPRE VISIBLE) */} +
+ + +
+
+
+ )} +
+ + {/* MODAL DE RECLAMO */} + + {showClaimModal && selectedItem && ( + setShowClaimModal(false)} + onSuccess={() => loadItemClaims(selectedItem.listing.id)} + /> + )} + + {/* MODAL DE RESOLUCION DE RECLAMO */} + + {resolvingClaim && selectedItem && ( + setResolvingClaim(null)} + onSuccess={() => { + loadItemClaims(selectedItem.listing.id); + loadHistory(); + }} + /> + )} + +
+ ); +} + +function InfoBox({ label, value, color = "text-slate-800" }: any) { + return ( +
+ {label} + {value} +
+ ); +} \ No newline at end of file diff --git a/frontend/counter-panel/src/pages/LoginPage.tsx b/frontend/counter-panel/src/pages/LoginPage.tsx index ce4bb42..9635160 100644 --- a/frontend/counter-panel/src/pages/LoginPage.tsx +++ b/frontend/counter-panel/src/pages/LoginPage.tsx @@ -13,9 +13,12 @@ export default function LoginPage() { try { const res = await api.post('/auth/login', { username, password }); localStorage.setItem('token', res.data.token); - localStorage.setItem('user', username); // Guardar usuario para mostrar en header + + // CAMBIO AQUÍ: Guardar como objeto stringificado + localStorage.setItem('user', JSON.stringify({ username })); + navigate('/'); - } catch (e) { + } catch { alert('Credenciales inválidas'); } }; diff --git a/frontend/counter-panel/src/pages/TreasuryPage.tsx b/frontend/counter-panel/src/pages/TreasuryPage.tsx new file mode 100644 index 0000000..8e91bce --- /dev/null +++ b/frontend/counter-panel/src/pages/TreasuryPage.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react'; +import api from '../services/api'; +import { + ShieldCheck, AlertCircle, CheckCircle2, + User as UserIcon, Clock +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useToast } from '../context/use-toast'; +import clsx from 'clsx'; + +export default function TreasuryPage() { + const { showToast } = useToast(); + const [pending, setPending] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedSession, setSelectedSession] = useState(null); + const [notes, setNotes] = useState(''); + + const loadPending = async () => { + setLoading(true); + try { + const res = await api.get('/cashsessions/pending'); + setPending(res.data); + } catch (e) { + showToast("Error al cargar sesiones pendientes", "error"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadPending(); }, []); + + const handleValidate = async () => { + if (!selectedSession) return; + try { + await api.post(`/cashsessions/${selectedSession.id}/validate`, JSON.stringify(notes), { + headers: { 'Content-Type': 'application/json' } + }); + showToast("Caja liquidada y archivada", "success"); + setSelectedSession(null); + setNotes(''); + loadPending(); + } catch (e) { + showToast("Error al validar", "error"); + } + }; + + if (loading) return
Cargando Tesorería...
; + + return ( +
+
+ Administración Central +

Validación de Cajas

+
+ +
+ + {/* LISTADO DE CAJAS PENDIENTES */} +
+

Sesiones esperando cierre definitivo

+ {pending.length === 0 ? ( +
+ +

No hay cajas pendientes de validación

+
+ ) : ( + pending.map(s => ( + setSelectedSession(s)} + className={clsx( + "p-6 bg-white rounded-[2rem] border-2 transition-all cursor-pointer flex justify-between items-center group", + selectedSession?.id === s.id ? "border-blue-600 shadow-xl shadow-blue-100" : "border-slate-100 hover:border-blue-200 shadow-sm" + )} + > +
+
+ +
+
+

{s.username}

+

+ Cerrada: {new Date(s.closingDate).toLocaleTimeString()} +

+
+
+
+

$ {(s.declaredCash + s.declaredCards + s.declaredTransfers).toLocaleString()}

+ PENDIENTE LIQUIDAR +
+
+ )) + )} +
+ + {/* PANEL DE ACCIÓN (DERECHA) */} +
+ + {selectedSession ? ( + +
+

+ Detalle de Liquidación +

+ +
+
+ + = 0 ? "text-emerald-400" : "text-rose-400"} /> +
+ +
+ +