Feat: Cambios Varios 2
This commit is contained in:
12
.env
Normal file
12
.env
Normal file
@@ -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"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/) ##
|
||||
|
||||
2
frontend/admin-panel/.env
Normal file
2
frontend/admin-panel/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5176/api
|
||||
VITE_BASE_URL=http://localhost:5176
|
||||
@@ -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: [] });
|
||||
});
|
||||
|
||||
|
||||
2
frontend/counter-panel/.env
Normal file
2
frontend/counter-panel/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5176/api
|
||||
VITE_BASE_URL=http://localhost:5176
|
||||
447
frontend/counter-panel/package-lock.json
generated
447
frontend/counter-panel/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
|
||||
<Route path="/" element={<FastEntryPage />} />
|
||||
<Route path="/cash-register" element={<CashRegisterPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/nuevo-aviso" element={<FastEntryPage />} />
|
||||
<Route path="/caja" element={<CashRegisterPage />} />
|
||||
<Route path="/analitica" element={<AdvancedAnalytics />} />
|
||||
<Route path="/historial" element={<HistoryPage />} />
|
||||
<Route path="/tesoreria" element={<TreasuryPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
200
frontend/counter-panel/src/components/CashClosingModal.tsx
Normal file
200
frontend/counter-panel/src/components/CashClosingModal.tsx
Normal file
@@ -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<any>(null);
|
||||
const [loadingSummary, setLoadingSummary] = useState(true);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [closedSessionId, setClosedSessionId] = useState<number | null>(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 (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[200] flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin text-white" size={40} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center z-[200] p-4 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="bg-white rounded-[2.5rem] shadow-2xl max-w-lg w-full overflow-hidden border border-white/20"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-slate-900 p-8 text-white relative">
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400">Finalización de Turno</span>
|
||||
<h2 className="text-3xl font-black tracking-tight">Cierre de Caja</h2>
|
||||
</div>
|
||||
{!done && (
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-xl transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{!done ? (
|
||||
<motion.div key="summary" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
|
||||
|
||||
{/* Visualización de Totales del Sistema */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Resumen de Valores en Caja</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<SummaryRow label="Fondo de Apertura" value={summary.openingBalance} icon={<Banknote size={16} />} />
|
||||
<SummaryRow label="Ventas Efectivo" value={summary.cashSales} icon={<Banknote size={16} />} isSale />
|
||||
<SummaryRow label="Ventas Tarjetas" value={summary.cardSales} icon={<CreditCard size={16} />} isSale />
|
||||
<SummaryRow label="Ventas Transferencia" value={summary.transferSales} icon={<ArrowRightLeft size={16} />} isSale />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total a Entregar */}
|
||||
<div className="bg-slate-900 p-6 rounded-[2rem] flex justify-between items-center text-white shadow-xl">
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase text-slate-500 block mb-1">Total Final a Entregar</span>
|
||||
<span className="text-4xl font-mono font-black text-green-400">
|
||||
$ {summary.totalExpected.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<CheckCircle2 size={40} className="text-green-500 opacity-20" />
|
||||
</div>
|
||||
|
||||
{/* Notas opcionales */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase ml-1">Notas u Observaciones</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Opcional: aclaraciones sobre el turno..."
|
||||
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl text-sm font-bold outline-none focus:border-blue-500 transition-all resize-none h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-2xl border border-amber-100 flex gap-3">
|
||||
<AlertCircle className="text-amber-500 shrink-0" size={18} />
|
||||
<p className="text-[10px] text-amber-800 font-bold leading-tight uppercase opacity-80">
|
||||
Al confirmar, declaras que el dinero físico coincide con este resumen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFinalClose}
|
||||
disabled={isClosing}
|
||||
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-xl hover:bg-blue-700 transition-all flex items-center justify-center gap-2 active:scale-95"
|
||||
>
|
||||
{isClosing ? 'Procesando...' : 'Confirmar y Cerrar Caja'}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="done" initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-center py-10">
|
||||
<div className="w-20 h-20 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 size={48} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 uppercase mb-2">¡Caja Cerrada!</h3>
|
||||
<p className="text-slate-500 font-bold text-sm mb-8">El turno ha finalizado correctamente. Los reportes han sido enviados a tesorería.</p>
|
||||
<button
|
||||
onClick={handleDownloadPdf}
|
||||
className="w-full py-4 bg-blue-600 text-white font-black uppercase text-xs rounded-2xl hover:bg-blue-700 transition-all mb-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Printer size={16} /> Descargar Acta de Cierre
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onComplete(); onClose(); }}
|
||||
className="w-full py-5 bg-slate-900 text-white font-black uppercase text-xs rounded-2xl hover:bg-black transition-all"
|
||||
>
|
||||
Volver al Inicio
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryRowProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
isSale?: boolean;
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value, icon, isSale }: SummaryRowProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center p-3.5 bg-slate-50 rounded-xl border border-slate-100 group hover:bg-white hover:border-blue-100 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-slate-400 group-hover:text-blue-500 transition-colors">{icon}</div>
|
||||
<span className="text-[10px] font-black text-slate-500 uppercase tracking-tight">{label}</span>
|
||||
</div>
|
||||
<span className={clsx("font-mono font-black text-sm", isSale ? "text-slate-800" : "text-slate-400")}>
|
||||
$ {value.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/counter-panel/src/components/CashOpeningModal.tsx
Normal file
119
frontend/counter-panel/src/components/CashOpeningModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Play, ShieldCheck, Lock, X } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useToast } from '../context/use-toast';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
|
||||
const { showToast } = useToast();
|
||||
const [openingBalance, setOpeningBalance] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (openingBalance < 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Enviamos el fondo inicial al endpoint que creamos en C#
|
||||
await api.post('/cashsessions/open', openingBalance);
|
||||
showToast("Caja abierta correctamente. ¡Buen turno!", "success");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
showToast("Error al abrir la sesión de caja", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-md z-[500] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white w-full max-w-md rounded-[2.5rem] shadow-2xl overflow-hidden border border-white/20"
|
||||
>
|
||||
{/* Header (Igual al anterior) */}
|
||||
<div className="p-8 bg-slate-900 text-white relative">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10"><Lock size={80} /></div>
|
||||
{/* Botón X de cerrar en el header */}
|
||||
|
||||
<div className="relative z-10">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 block mb-2">Inicio de Jornada</span>
|
||||
<h2 className="text-3xl font-black tracking-tight uppercase">Apertura de Caja</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="absolute z-20 top-6 right-6 p-2 hover:bg-white/10 rounded-xl transition-colors text-slate-400 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleOpen} className="p-8 space-y-8">
|
||||
<div className="bg-blue-50 border border-blue-100 p-4 rounded-2xl flex gap-4">
|
||||
<ShieldCheck className="text-blue-600 shrink-0" size={24} />
|
||||
<p className="text-[11px] text-blue-800 font-bold leading-tight uppercase opacity-80">
|
||||
Debe declarar el fondo de inicio (dinero para cambio) recibido de tesorería para habilitar los cobros.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Fondo Inicial en Efectivo</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-300 font-mono text-2xl font-bold">$</div>
|
||||
<input
|
||||
type="number"
|
||||
required autoFocus
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full pl-12 pr-6 py-6 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-600 focus:bg-white text-3xl font-mono font-black text-slate-900 transition-all shadow-inner"
|
||||
placeholder="0.00"
|
||||
value={openingBalance || ''}
|
||||
onChange={e => setOpeningBalance(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
<Play size={18} fill="currentColor" /> {loading ? 'Procesando...' : 'Iniciar Sesión de Trabajo'}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-200 transition-all active:scale-95"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
<Play size={18} fill="currentColor" /> {loading ? 'Cargando...' : 'Iniciar Turno'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/counter-panel/src/components/ClaimModal.tsx
Normal file
119
frontend/counter-panel/src/components/ClaimModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, AlertTriangle, Send, MessageSquare } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../context/use-toast';
|
||||
|
||||
interface ClaimModalProps {
|
||||
listingId: number;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CLAIM_TYPES = [
|
||||
"Error en Texto",
|
||||
"Error en Cobro / Importe",
|
||||
"Datos de Cliente incorrectos",
|
||||
"Solicitud de Baja",
|
||||
"Queja de Cliente",
|
||||
"Otros"
|
||||
];
|
||||
|
||||
export default function ClaimModal({ listingId, onClose, onSuccess }: ClaimModalProps) {
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
claimType: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.claimType || !formData.description) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/claims', {
|
||||
listingId,
|
||||
claimType: formData.claimType,
|
||||
description: formData.description
|
||||
});
|
||||
showToast("Reclamo registrado correctamente", "success");
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showToast("Error al registrar el reclamo", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-[300] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden border border-slate-100"
|
||||
>
|
||||
<div className="p-8 bg-slate-900 text-white flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-600 rounded-2xl shadow-lg shadow-blue-500/20">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400">Atención al Cliente</span>
|
||||
<h3 className="text-xl font-black uppercase tracking-tight">Nuevo Reclamo</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><X /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Motivo del reclamo</label>
|
||||
<select
|
||||
required
|
||||
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 font-bold text-slate-700"
|
||||
value={formData.claimType}
|
||||
onChange={e => setFormData({ ...formData, claimType: e.target.value })}
|
||||
>
|
||||
<option value="">Seleccione una categoría...</option>
|
||||
{CLAIM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Descripción detallada</label>
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-4 text-slate-300" size={20} />
|
||||
<textarea
|
||||
required
|
||||
className="w-full p-4 pl-12 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 font-bold text-slate-700 min-h-[150px] resize-none"
|
||||
placeholder="Explique lo ocurrido con el máximo detalle posible..."
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-4 bg-slate-50 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-100 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-[1.5] py-4 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Send size={16} /> {loading ? 'Registrando...' : 'Confirmar Reporte'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Bell, X, Info, AlertTriangle, Clock } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function NotificationDropdown({ notifications, onClose, onRefresh }: any) {
|
||||
|
||||
const markAsRead = async (id: number) => {
|
||||
await api.put(`/notifications/${id}/read`);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-80 bg-white rounded-[2rem] shadow-2xl border border-slate-100 overflow-hidden z-[100]"
|
||||
>
|
||||
<div className="p-5 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-slate-900">Notificaciones</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-10 text-center text-slate-300">
|
||||
<Bell size={32} className="mx-auto mb-2 opacity-20" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest">Sin alertas nuevas</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n: any) => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => markAsRead(n.id)}
|
||||
className={clsx(
|
||||
"p-4 border-b border-slate-50 flex gap-4 cursor-pointer transition-all hover:bg-slate-50 relative",
|
||||
!n.isRead && "bg-blue-50/30"
|
||||
)}
|
||||
>
|
||||
{!n.isRead && <div className="absolute left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-blue-600 rounded-full"></div>}
|
||||
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-2xl flex items-center justify-center shrink-0",
|
||||
n.type === 'warning' ? "bg-amber-100 text-amber-600" :
|
||||
n.type === 'error' ? "bg-rose-100 text-rose-600" : "bg-blue-100 text-blue-600"
|
||||
)}>
|
||||
{n.type === 'warning' ? <AlertTriangle size={18} /> : <Info size={18} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-black text-slate-800 uppercase leading-tight mb-1">{n.title}</p>
|
||||
<p className="text-[11px] text-slate-500 font-medium leading-snug">{n.message}</p>
|
||||
<p className="text-[8px] text-slate-400 font-bold uppercase mt-2 flex items-center gap-1">
|
||||
<Clock size={10} /> {new Date(n.createdAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
263
frontend/counter-panel/src/components/PaymentModal.tsx
Normal file
263
frontend/counter-panel/src/components/PaymentModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Banknote, ArrowRightLeft, DollarSign, X } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Interfaz de Pago
|
||||
export interface Payment {
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
cardPlan?: string;
|
||||
surcharge: number;
|
||||
}
|
||||
|
||||
// Props del Modal de Pagos
|
||||
interface PaymentModalProps {
|
||||
totalAmount: number;
|
||||
onConfirm: (payments: Payment[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Planes de Tarjetas de Crédito con sus recargos
|
||||
const CARD_PLANS = [
|
||||
{ value: 'Ahora12', label: 'Ahora 12', surcharge: 15 },
|
||||
{ value: 'Ahora18', label: 'Ahora 18', surcharge: 20 },
|
||||
{ value: 'Credit3', label: '3 Cuotas', surcharge: 8 },
|
||||
{ value: 'Credit6', label: '6 Cuotas', surcharge: 12 },
|
||||
];
|
||||
|
||||
export default function PaymentModal({ totalAmount, onConfirm, onCancel }: PaymentModalProps) {
|
||||
// Estado de pagos acumulados
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [currentMethod, setCurrentMethod] = useState('Cash');
|
||||
const [currentAmount, setCurrentAmount] = useState(totalAmount);
|
||||
const [currentPlan, setCurrentPlan] = useState('');
|
||||
|
||||
// Cálculos de totales
|
||||
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const pendingAmount = totalAmount - totalPaid;
|
||||
|
||||
// Actualizar monto actual cuando cambia el pendiente
|
||||
useEffect(() => {
|
||||
setCurrentAmount(Math.max(0, pendingAmount));
|
||||
}, [pendingAmount]);
|
||||
|
||||
// Agregar un nuevo pago a la lista
|
||||
const addPayment = () => {
|
||||
if (currentAmount <= 0 || currentAmount > pendingAmount) {
|
||||
alert('❌ Monto inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMethod === 'Credit' && !currentPlan) {
|
||||
alert('❌ Seleccione un plan de cuotas');
|
||||
return;
|
||||
}
|
||||
|
||||
const planInfo = CARD_PLANS.find(p => p.value === currentPlan);
|
||||
const surcharge = currentMethod === 'Credit' && planInfo ? (currentAmount * planInfo.surcharge) / 100 : 0;
|
||||
|
||||
const newPayment: Payment = {
|
||||
amount: currentAmount,
|
||||
paymentMethod: currentMethod,
|
||||
cardPlan: currentMethod === 'Credit' ? currentPlan : undefined,
|
||||
surcharge
|
||||
};
|
||||
|
||||
setPayments([...payments, newPayment]);
|
||||
setCurrentMethod('Cash');
|
||||
setCurrentPlan('');
|
||||
};
|
||||
|
||||
// Eliminar un pago de la lista
|
||||
const removePayment = (index: number) => {
|
||||
setPayments(payments.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Confirmar todos los pagos
|
||||
const handleConfirm = () => {
|
||||
if (pendingAmount > 0) {
|
||||
alert('❌ Aún falta completar el pago');
|
||||
return;
|
||||
}
|
||||
onConfirm(payments);
|
||||
};
|
||||
|
||||
// Total incluyendo recargos
|
||||
const totalWithSurcharges = payments.reduce((sum, p) => sum + p.amount + p.surcharge, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Encabezado */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-6 rounded-t-3xl">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black">Procesar Pago</h2>
|
||||
<p className="text-sm text-blue-100 mt-1">Configure los medios de pago</p>
|
||||
</div>
|
||||
<button onClick={onCancel} className="hover:bg-white/20 p-2 rounded-full transition">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Resumen de Importes */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
|
||||
<div className="text-xs text-blue-600 font-bold uppercase mb-1">Total Original</div>
|
||||
<div className="text-2xl font-black text-blue-900">${totalAmount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl border border-green-200">
|
||||
<div className="text-xs text-green-600 font-bold uppercase mb-1">Pagado</div>
|
||||
<div className="text-2xl font-black text-green-900">${totalPaid.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className={clsx("p-4 rounded-xl border", pendingAmount > 0 ? "bg-orange-50 border-orange-200" : "bg-gray-50 border-gray-200")}>
|
||||
<div className={clsx("text-xs font-bold uppercase mb-1", pendingAmount > 0 ? "text-orange-600" : "text-gray-500")}>Pendiente</div>
|
||||
<div className={clsx("text-2xl font-black", pendingAmount > 0 ? "text-orange-900" : "text-gray-400")}>${pendingAmount.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Pagos Agregados */}
|
||||
{payments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Pagos Agregados</h3>
|
||||
{payments.map((payment, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{payment.paymentMethod === 'Cash' && <Banknote className="text-green-600" size={20} />}
|
||||
{payment.paymentMethod === 'Debit' && <CreditCard className="text-blue-600" size={20} />}
|
||||
{payment.paymentMethod === 'Credit' && <CreditCard className="text-purple-600" size={20} />}
|
||||
{payment.paymentMethod === 'Transfer' && <ArrowRightLeft className="text-indigo-600" size={20} />}
|
||||
<div>
|
||||
<div className="font-bold text-sm">
|
||||
{payment.paymentMethod === 'Cash' && 'Efectivo'}
|
||||
{payment.paymentMethod === 'Debit' && 'Débito'}
|
||||
{payment.paymentMethod === 'Credit' && payment.cardPlan}
|
||||
{payment.paymentMethod === 'Transfer' && 'Transferencia'}
|
||||
</div>
|
||||
{payment.surcharge > 0 && (
|
||||
<div className="text-xs text-orange-600">+${payment.surcharge.toLocaleString()} recargo</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-black text-lg">${payment.amount.toLocaleString()}</span>
|
||||
<button onClick={() => removePayment(index)} className="text-red-500 hover:bg-red-50 p-1 rounded">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario para Agregar Pago */}
|
||||
{pendingAmount > 0 && (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Agregar Pago</h3>
|
||||
|
||||
{/* Selector de Método de Pago */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ value: 'Cash', label: 'Efectivo', icon: Banknote, color: 'green' },
|
||||
{ value: 'Debit', label: 'Débito', icon: CreditCard, color: 'blue' },
|
||||
{ value: 'Credit', label: 'Crédito', icon: CreditCard, color: 'purple' },
|
||||
{ value: 'Transfer', label: 'Transferencia', icon: ArrowRightLeft, color: 'indigo' }
|
||||
].map((method) => (
|
||||
<button
|
||||
key={method.value}
|
||||
onClick={() => setCurrentMethod(method.value)}
|
||||
className={clsx(
|
||||
"p-3 rounded-lg border-2 transition-all flex flex-col items-center gap-1",
|
||||
currentMethod === method.value
|
||||
? `border-${method.color}-600 bg-${method.color}-50`
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<method.icon size={20} className={currentMethod === method.value ? `text-${method.color}-600` : 'text-gray-400'} />
|
||||
<span className="text-xs font-bold">{method.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selector de Plan de Cuotas (solo para Crédito) */}
|
||||
{currentMethod === 'Credit' && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 uppercase mb-2">Plan de Cuotas</label>
|
||||
<select
|
||||
value={currentPlan}
|
||||
onChange={(e) => setCurrentPlan(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg font-bold"
|
||||
>
|
||||
<option value="">-- Seleccionar Plan --</option>
|
||||
{CARD_PLANS.map(plan => (
|
||||
<option key={plan.value} value={plan.value}>
|
||||
{plan.label} (+{plan.surcharge}% recargo)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input de Monto */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 uppercase mb-2">Monto</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-3 text-gray-400" size={20} />
|
||||
<input
|
||||
type="number"
|
||||
value={currentAmount}
|
||||
onChange={(e) => setCurrentAmount(parseFloat(e.target.value) || 0)}
|
||||
className="w-full pl-10 p-3 border border-gray-300 rounded-lg font-black text-xl"
|
||||
max={pendingAmount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addPayment}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-black uppercase transition"
|
||||
>
|
||||
+ Agregar Pago
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resumen Final con Recargos */}
|
||||
{totalWithSurcharges !== totalAmount && (
|
||||
<div className="bg-orange-50 border border-orange-200 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-orange-700">Total con Recargos</span>
|
||||
<span className="text-2xl font-black text-orange-900">${totalWithSurcharges.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botones de Acción */}
|
||||
<div className="sticky bottom-0 bg-gray-50 p-6 rounded-b-3xl border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-white border-2 border-gray-300 text-gray-700 py-4 rounded-xl font-black uppercase hover:bg-gray-100 transition"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={pendingAmount > 0}
|
||||
className={clsx(
|
||||
"flex-1 py-4 rounded-xl font-black uppercase transition",
|
||||
pendingAmount > 0
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-green-600 hover:bg-green-700 text-white"
|
||||
)}
|
||||
>
|
||||
{pendingAmount > 0 ? 'Completar Pagos' : 'Confirmar Cobro'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
159
frontend/counter-panel/src/components/ResolveClaimModal.tsx
Normal file
159
frontend/counter-panel/src/components/ResolveClaimModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, CheckCircle2, Save, FileText, Calendar, Clock, Wrench, ChevronRight } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function ResolveClaimModal({ claim, listing, onClose, onSuccess }: any) {
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
|
||||
// Usamos valores defensivos para que no rompa si listing es undefined
|
||||
const [solutionData, setSolutionData] = useState({
|
||||
solution: '',
|
||||
applyAdjustments: false,
|
||||
newPrintText: listing?.printText || listing?.description || '',
|
||||
newStartDate: listing?.printStartDate ? listing.printStartDate.split('T')[0] : '',
|
||||
newDaysCount: listing?.printDaysCount || 3
|
||||
});
|
||||
|
||||
const handleResolve = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Enviamos la resolución + los ajustes técnicos
|
||||
await api.put(`/claims/${claim.id}/resolve`, {
|
||||
solution: solutionData.solution,
|
||||
// Solo enviamos ajustes si el switch está activo
|
||||
newPrintText: solutionData.applyAdjustments ? solutionData.newPrintText : null,
|
||||
newStartDate: solutionData.applyAdjustments ? solutionData.newStartDate : null,
|
||||
newDaysCount: solutionData.applyAdjustments ? solutionData.newDaysCount : null,
|
||||
});
|
||||
|
||||
showToast("Solución aplicada y aviso actualizado con éxito", "success");
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showToast("Error al procesar la resolución técnica", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-[400] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl overflow-hidden border border-slate-100"
|
||||
>
|
||||
<div className="p-6 bg-emerald-600 text-white flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 size={24} />
|
||||
<h3 className="text-lg font-black uppercase tracking-tight">Aplicar Solución Técnica</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-xl"><X /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleResolve} className="p-8 space-y-6 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||
|
||||
{/* INFO DEL RECLAMO */}
|
||||
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">Motivo: {claim.claimType}</p>
|
||||
<p className="text-xs font-bold text-slate-700 italic">"{claim.description}"</p>
|
||||
</div>
|
||||
|
||||
{/* TEXTO DE LA SOLUCIÓN */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Informe de Resolución</label>
|
||||
<textarea
|
||||
required
|
||||
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-emerald-500 font-bold text-slate-700 min-h-[100px] resize-none text-sm"
|
||||
placeholder="Describa qué acciones tomó para resolver este reclamo..."
|
||||
value={solutionData.solution}
|
||||
onChange={e => setSolutionData({ ...solutionData, solution: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HERRAMIENTAS DE AJUSTE (Técnico) */}
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowTools(!showTools);
|
||||
setSolutionData({ ...solutionData, applyAdjustments: !showTools });
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full p-4 rounded-2xl flex items-center justify-between transition-all",
|
||||
showTools ? "bg-blue-600 text-white shadow-lg" : "bg-blue-50 text-blue-600 hover:bg-blue-100"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Wrench size={18} />
|
||||
<span className="text-xs font-black uppercase tracking-widest">Habilitar Ajustes Técnicos</span>
|
||||
</div>
|
||||
<ChevronRight className={clsx("transition-transform", showTools && "rotate-90")} size={18} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTools && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-6 mt-2 space-y-6 bg-slate-50 rounded-3xl border-2 border-blue-100">
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-blue-600 uppercase flex items-center gap-2">
|
||||
<FileText size={12} /> Corrección de Texto (Imprenta)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full p-4 bg-white border border-blue-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs uppercase"
|
||||
value={solutionData.newPrintText}
|
||||
onChange={e => setSolutionData({ ...solutionData, newPrintText: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-blue-600 uppercase flex items-center gap-2">
|
||||
<Calendar size={12} /> Nueva Fecha Inicio
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full p-3 bg-white border border-blue-200 rounded-xl text-xs font-bold"
|
||||
value={solutionData.newStartDate}
|
||||
onChange={e => setSolutionData({ ...solutionData, newStartDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-blue-600 uppercase flex items-center gap-2">
|
||||
<Clock size={12} /> Días de extensión
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-3 bg-white border border-blue-200 rounded-xl text-xs font-bold"
|
||||
value={solutionData.newDaysCount}
|
||||
onChange={e => setSolutionData({ ...solutionData, newDaysCount: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-5 bg-emerald-600 text-white font-black uppercase text-xs tracking-[.2em] rounded-2xl shadow-xl shadow-emerald-200 hover:bg-emerald-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={18} /> {loading ? 'Procesando...' : 'Confirmar y Aplicar Cambios'}
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/counter-panel/src/components/SettingsPanel.tsx
Normal file
91
frontend/counter-panel/src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, User, Lock, Save, ShieldCheck } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../context/use-toast';
|
||||
|
||||
export default function SettingsPanel({ onClose }: any) {
|
||||
const { showToast } = useToast();
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const [passwords, setPasswords] = useState({ old: '', new: '' });
|
||||
|
||||
const handleUpdatePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Endpoint que deberías tener en AuthController o UsersController
|
||||
await api.put('/users/change-password', passwords);
|
||||
showToast("Contraseña actualizada con éxito", "success");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showToast("Error: La contraseña actual es incorrecta", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex justify-end">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose} className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="relative w-full max-w-sm bg-white h-full shadow-2xl flex flex-col border-l border-slate-200"
|
||||
>
|
||||
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter">Mi Perfil</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-xl transition-colors text-slate-400"><X /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex-1 overflow-y-auto space-y-10 custom-scrollbar">
|
||||
{/* Info del Cajero */}
|
||||
<section className="text-center">
|
||||
<div className="w-20 h-20 bg-blue-600 rounded-[2rem] flex items-center justify-center text-white mx-auto mb-4 shadow-xl shadow-blue-200">
|
||||
<User size={40} />
|
||||
</div>
|
||||
<h4 className="text-lg font-black text-slate-900 uppercase">{user.username}</h4>
|
||||
<span className="text-[10px] font-black text-blue-500 bg-blue-50 px-3 py-1 rounded-full uppercase tracking-widest border border-blue-100">
|
||||
Cajero Central
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Cambio de Contraseña */}
|
||||
<section className="space-y-6">
|
||||
<h5 className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Lock size={14} /> Seguridad de Acceso
|
||||
</h5>
|
||||
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Contraseña Actual</label>
|
||||
<input
|
||||
type="password" required
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold"
|
||||
value={passwords.old}
|
||||
onChange={e => setPasswords({ ...passwords, old: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Nueva Contraseña</label>
|
||||
<input
|
||||
type="password" required
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold"
|
||||
value={passwords.new}
|
||||
onChange={e => setPasswords({ ...passwords, new: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full py-4 bg-slate-900 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-black transition-all flex items-center justify-center gap-2">
|
||||
<Save size={16} /> Guardar Cambios
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100 flex items-center gap-3">
|
||||
<ShieldCheck size={20} className="text-emerald-500" />
|
||||
<p className="text-[10px] text-emerald-800 font-bold uppercase leading-tight">Su terminal cuenta con cifrado de punto a punto activo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
trend?: {
|
||||
value: number;
|
||||
isUp: boolean;
|
||||
};
|
||||
color: 'blue' | 'purple' | 'emerald' | 'amber' | 'rose';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
blue: 'from-blue-500/10 to-blue-600/10 text-blue-600 border-blue-200/50',
|
||||
purple: 'from-purple-500/10 to-purple-600/10 text-purple-600 border-purple-200/50',
|
||||
emerald: 'from-emerald-500/10 to-emerald-600/10 text-emerald-600 border-emerald-200/50',
|
||||
amber: 'from-amber-500/10 to-amber-600/10 text-amber-600 border-amber-200/50',
|
||||
rose: 'from-rose-500/10 to-rose-600/10 text-rose-600 border-rose-200/50',
|
||||
};
|
||||
|
||||
export default function StatsCard({ title, value, icon: Icon, trend, color, delay = 0 }: StatsCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
className={clsx(
|
||||
"relative overflow-hidden rounded-2xl border bg-white/50 backdrop-blur-xl p-6 shadow-sm transition-all hover:shadow-md",
|
||||
colorMap[color]
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">{title}</p>
|
||||
<h3 className="mt-1 text-3xl font-bold tracking-tight text-slate-900">{value}</h3>
|
||||
|
||||
{trend && (
|
||||
<div className={clsx(
|
||||
"mt-2 flex items-center text-xs font-semibold",
|
||||
trend.isUp ? "text-emerald-600" : "text-rose-600"
|
||||
)}>
|
||||
{trend.isUp ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
<span className="ml-1 font-normal text-slate-400">vs mes anterior</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-inner",
|
||||
colorMap[color]
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Elemento decorativo de fondo */}
|
||||
<div className="absolute -bottom-6 -right-6 h-24 w-24 rounded-full bg-current opacity-[0.03]" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
69
frontend/counter-panel/src/context/ToastContext.tsx
Normal file
69
frontend/counter-panel/src/context/ToastContext.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CheckCircle2, AlertCircle, X, Info } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { ToastContext, type Toast, type ToastType } from './use-toast';
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-8 right-8 z-[1000] flex flex-col gap-3 pointer-events-none">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 50, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 20, scale: 0.5, transition: { duration: 0.2 } }}
|
||||
className={clsx(
|
||||
"pointer-events-auto flex items-center gap-3 px-5 py-4 rounded-2xl shadow-2xl border backdrop-blur-md min-w-[300px] max-w-md",
|
||||
toast.type === 'success' && "bg-emerald-50/90 border-emerald-100 text-emerald-900",
|
||||
toast.type === 'error' && "bg-rose-50/90 border-rose-100 text-rose-900",
|
||||
toast.type === 'info' && "bg-blue-50/90 border-blue-100 text-blue-900"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"p-2 rounded-xl",
|
||||
toast.type === 'success' && "bg-emerald-500 text-white",
|
||||
toast.type === 'error' && "bg-rose-500 text-white",
|
||||
toast.type === 'info' && "bg-blue-500 text-white"
|
||||
)}>
|
||||
{toast.type === 'success' && <CheckCircle2 size={18} />}
|
||||
{toast.type === 'error' && <AlertCircle size={18} />}
|
||||
{toast.type === 'info' && <Info size={18} />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-black tracking-tight leading-tight">{toast.message}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="p-1 hover:bg-black/5 rounded-lg transition-colors text-slate-400"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
21
frontend/counter-panel/src/context/use-toast.ts
Normal file
21
frontend/counter-panel/src/context/use-toast.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
export interface ToastContextType {
|
||||
showToast: (message: string, type: ToastType) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||
return context;
|
||||
};
|
||||
31
frontend/counter-panel/src/hooks/useCashSession.ts
Normal file
31
frontend/counter-panel/src/hooks/useCashSession.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
export function useCashSession() {
|
||||
const [session, setSession] = useState<{ isOpen: boolean; data: any | null }>({
|
||||
isOpen: false,
|
||||
data: null
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/cashsessions/status');
|
||||
setSession({
|
||||
isOpen: res.data.isOpen,
|
||||
data: res.data.session
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error al verificar estado de caja", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
return { session, loading, refreshSession: checkStatus };
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Monitor, LogOut } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, PlusCircle, Banknote, LogOut,
|
||||
ChevronLeft, ChevronRight, Settings, Bell,
|
||||
Search, User as UserIcon, Monitor,
|
||||
TrendingUp, ClipboardList, ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
import NotificationDropdown from '../components/NotificationDropdown';
|
||||
import SettingsPanel from '../components/SettingsPanel';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function CounterLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [notifications, setNotifications] = useState<any>({ items: [], unreadCount: 0 });
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// Parsing del usuario más seguro
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const user = storedUser && storedUser.startsWith('{')
|
||||
? JSON.parse(storedUser)
|
||||
: { username: storedUser || "Admin" };
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('¿Cerrar sesión de caja?')) {
|
||||
@@ -14,85 +34,183 @@ export default function CounterLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await api.get('/notifications');
|
||||
setNotifications(res.data);
|
||||
} catch (e) { console.error("Error al obtener notificaciones", e); }
|
||||
};
|
||||
|
||||
// Listener de atajos de teclado completo (F1 - F9)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'F2':
|
||||
e.preventDefault();
|
||||
navigate('/');
|
||||
break;
|
||||
case 'F4':
|
||||
e.preventDefault();
|
||||
navigate('/cash-register');
|
||||
break;
|
||||
case 'F10':
|
||||
// F10 se maneja en el componente hijo (FastEntryPage),
|
||||
// pero prevenimos el default del navegador
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Opcional: Cancelar o Logout rápido
|
||||
break;
|
||||
const map: Record<string, string> = {
|
||||
'F1': '/dashboard',
|
||||
'F2': '/nuevo-aviso',
|
||||
'F4': '/caja',
|
||||
'F6': '/analitica',
|
||||
'F8': '/historial',
|
||||
'F9': '/tesoreria'
|
||||
};
|
||||
|
||||
if (map[e.key]) {
|
||||
e.preventDefault();
|
||||
navigate(map[e.key]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const interval = setInterval(fetchNotifications, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', label: 'Panel Principal', icon: LayoutDashboard, shortcut: 'F1' },
|
||||
{ path: '/nuevo-aviso', label: 'Nuevo Aviso', icon: PlusCircle, shortcut: 'F2' },
|
||||
{ path: '/caja', label: 'Caja Diaria', icon: Banknote, shortcut: 'F4' },
|
||||
{ path: '/historial', label: 'Consultas', icon: ClipboardList, shortcut: 'F8' },
|
||||
{ path: '/analitica', label: 'Analítica', icon: TrendingUp, shortcut: 'F6' },
|
||||
{ path: '/tesoreria', label: 'Tesorería', icon: ShieldCheck, shortcut: 'F9' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-800 text-white p-3 flex justify-between items-center shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-bold text-xl tracking-wider flex items-center gap-2">
|
||||
<Monitor className="text-green-400" />
|
||||
SIG-CM <span className="text-slate-400 text-sm font-normal">MOSTRADOR v1.0</span>
|
||||
<div className="flex h-screen bg-[#f8fafc] overflow-hidden">
|
||||
{/* --- SIDEBAR --- */}
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: isSidebarOpen ? 260 : 80 }}
|
||||
className="relative bg-slate-900 text-slate-300 flex flex-col z-30 shadow-2xl"
|
||||
>
|
||||
<div className="p-6 flex items-center gap-3 border-b border-slate-800/50">
|
||||
<div className="bg-blue-600 p-2 rounded-xl shadow-lg shadow-blue-500/20">
|
||||
<Monitor size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="h-6 w-px bg-slate-600 mx-2"></div>
|
||||
<div className="text-sm text-slate-300">
|
||||
Sucursal: <span className="text-white font-mono">CENTRAL</span> | Caja: <span className="text-white font-mono">01</span>
|
||||
{isSidebarOpen && (
|
||||
<motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="font-bold text-xl tracking-tight text-white">
|
||||
SIG-CM
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto custom-scrollbar">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-xl transition-all group relative",
|
||||
isActive ? "bg-blue-600 text-white shadow-lg shadow-blue-500/20" : "hover:bg-slate-800 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<item.icon size={22} className={clsx(isActive ? "text-white" : "text-slate-400 group-hover:text-blue-400")} />
|
||||
{isSidebarOpen && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex-1 flex justify-between items-center">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-[10px] bg-slate-800 px-1.5 py-0.5 rounded opacity-50 font-mono">{item.shortcut}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-800/50">
|
||||
<button onClick={handleLogout} className="flex items-center gap-3 w-full px-3 py-3 rounded-xl hover:bg-rose-500/10 hover:text-rose-400 transition-colors group">
|
||||
<LogOut size={22} className="text-slate-500 group-hover:text-rose-400" />
|
||||
{isSidebarOpen && <span className="font-medium text-sm">Cerrar Sesión</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="absolute -right-3 top-20 bg-white text-slate-900 border border-slate-200 rounded-full p-1 shadow-md hover:scale-110 transition-transform hidden lg:block">
|
||||
{isSidebarOpen ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
</motion.aside>
|
||||
|
||||
{/* --- MAIN CONTENT --- */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
|
||||
{/* TOP HEADER */}
|
||||
<header className="h-16 bg-white/80 backdrop-blur-md border-b border-slate-200 px-8 flex items-center justify-between z-20 sticky top-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative max-w-md w-full hidden md:block">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input type="text" placeholder="Buscar avisos, clientes..." className="w-full pl-10 pr-4 py-2 bg-slate-100 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 transition-all outline-none font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTÓN SALIR CONECTADO */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-red-400 hover:text-red-300 flex items-center gap-1 text-sm font-bold transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut size={16} /> SALIR
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-hidden flex">
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* CAMPANITA */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className={clsx(
|
||||
"relative p-2.5 rounded-xl transition-all",
|
||||
showNotifications ? "bg-blue-600 text-white shadow-lg" : "text-slate-500 hover:text-blue-600 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{notifications.unreadCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-rose-500 text-white text-[9px] font-black flex items-center justify-center rounded-full border-2 border-white shadow-sm">
|
||||
{notifications.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Footer con Barra de Atajos */}
|
||||
<footer className="bg-slate-900 text-white p-2 flex gap-4 text-sm font-mono border-t-4 border-blue-600">
|
||||
<button onClick={() => navigate('/')} className="hover:bg-slate-800 p-1 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
|
||||
<ShortcutKey k="F2" label="Nuevo Aviso" active={location.pathname === '/'} />
|
||||
</button>
|
||||
<button onClick={() => navigate('/cash-register')} className="hover:bg-slate-800 p-1 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
|
||||
<ShortcutKey k="F4" label="Caja Diaria" active={location.pathname === '/cash-register'} />
|
||||
</button>
|
||||
<div className="opacity-75 cursor-help" title="Presione F10 en la pantalla de carga">
|
||||
<ShortcutKey k="F10" label="Cobrar/Imprimir" />
|
||||
</div>
|
||||
<div className="opacity-75">
|
||||
<ShortcutKey k="ESC" label="Cancelar" />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<AnimatePresence>
|
||||
{showNotifications && (
|
||||
<NotificationDropdown
|
||||
notifications={notifications.items}
|
||||
onClose={() => setShowNotifications(false)}
|
||||
onRefresh={fetchNotifications}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
function ShortcutKey({ k, label, active = false }: { k: string, label: string, active?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded ${active ? 'bg-blue-700 text-white' : 'text-slate-400'}`}>
|
||||
<span className="bg-slate-700 px-2 rounded text-white font-bold">{k}</span>
|
||||
<span>{label}</span>
|
||||
{/* ENGRANAJE */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className={clsx(
|
||||
"p-2.5 rounded-xl transition-all",
|
||||
showSettings ? "bg-blue-600 text-white shadow-lg" : "text-slate-500 hover:text-blue-600 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
|
||||
<div className="h-6 w-px bg-slate-200 mx-2" />
|
||||
|
||||
{/* USUARIO */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-black text-slate-900 leading-none">{user.username}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-1">Cajero Central</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-[1rem] bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg border-2 border-white">
|
||||
<UserIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto bg-[#f8fafc]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* PANEL DE AJUSTES (Movido fuera del flujo del header para mejor renderizado) */}
|
||||
<AnimatePresence>
|
||||
{showSettings && (
|
||||
<SettingsPanel onClose={() => setShowSettings(false)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
frontend/counter-panel/src/pages/AdminDashboard.tsx
Normal file
251
frontend/counter-panel/src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
AreaChart, Area, PieChart, Pie, Cell
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, DollarSign, FileText, Calendar,
|
||||
Download, Clock, Zap, ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { dashboardService } from '../services/dashboardService';
|
||||
import api from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DashboardStats {
|
||||
revenueToday: number;
|
||||
adsToday: number;
|
||||
ticketAverage: number;
|
||||
paperOccupation: number;
|
||||
weeklyTrend: Array<{ day: string; amount: number }>;
|
||||
channelMix: Array<{ name: string; value: number }>;
|
||||
}
|
||||
|
||||
interface RecentListing {
|
||||
id: number;
|
||||
title: string;
|
||||
categoryName: string;
|
||||
createdAt: string;
|
||||
adFee: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#f43f5e'];
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentAds, setRecentAds] = useState<RecentListing[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const stats = await dashboardService.getStats();
|
||||
setData(stats);
|
||||
const res = await api.get('/listings');
|
||||
setRecentAds(res.data.slice(0, 5));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Formateadores locales
|
||||
const formatCurrency = (val: number) =>
|
||||
val.toLocaleString('es-AR', { minimumFractionDigits: 2 });
|
||||
|
||||
const formatLocalTime = (dateString: string) => {
|
||||
if (!dateString) return "--:--";
|
||||
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'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading || !data) return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-50">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] p-6 space-y-6">
|
||||
|
||||
{/* HEADER COMPACTO */}
|
||||
<div className="flex items-end justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Inteligencia de Negocio</span>
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 uppercase flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-1.5 h-6 rounded-full"></span>
|
||||
Métricas Globales
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mt-1 opacity-80">Estado operativo del diario en tiempo real</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button className="flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-600 shadow-sm border border-slate-200 hover:bg-slate-50 transition-all">
|
||||
<Download size={14} /> Exportar
|
||||
</button>
|
||||
<button className="flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white shadow-lg shadow-blue-500/20 hover:bg-blue-700 transition-all">
|
||||
<Calendar size={14} /> Últimos 30 días
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRID DE STATS REFINADO */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard title="Recaudación Hoy" value={`$ ${formatCurrency(data.revenueToday)}`} icon={DollarSign} color="blue" delay={0.1} />
|
||||
<StatsCard title="Avisos Publicados" value={data.adsToday} icon={FileText} color="emerald" delay={0.2} />
|
||||
<StatsCard title="Ticket Promedio" value={`$ ${formatCurrency(data.ticketAverage)}`} icon={TrendingUp} color="purple" delay={0.3} />
|
||||
<StatsCard title="Ocupación Papel" value={`${data.paperOccupation.toFixed(1)}%`} icon={Zap} color="amber" delay={0.4} />
|
||||
</div>
|
||||
|
||||
{/* SECCIÓN GRÁFICOS */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Tendencia de Ingresos */}
|
||||
<div className="lg:col-span-2 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em]">Tendencia Semanal</h3>
|
||||
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2.5 py-1 rounded-lg border border-blue-100">+12% vs semana anterior</span>
|
||||
</div>
|
||||
<div className="h-[280px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.weeklyTrend}>
|
||||
<defs>
|
||||
<linearGradient id="colorRev" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 'bold' }} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 'bold' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)', fontSize: '12px', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="amount" stroke="#3b82f6" strokeWidth={4} fillOpacity={1} fill="url(#colorRev)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canales de Venta */}
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<h3 className="mb-8 text-xs font-black text-slate-500 uppercase tracking-[0.2em]">Mix de Canales</h3>
|
||||
<div className="h-[220px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={data.channelMix} innerRadius={65} outerRadius={85} paddingAngle={8} dataKey="value">
|
||||
{data.channelMix.map((_, idx) => <Cell key={idx} fill={COLORS[idx % COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
{data.channelMix.map((item, idx) => (
|
||||
<div key={item.name} className="flex items-center justify-between bg-slate-50 px-4 py-2 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: COLORS[idx] }} />
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-tighter">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-xs font-black text-slate-900">{item.value} ads</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLA DE ÚLTIMAS OPERACIONES (ESTILO CAJA DIARIA) */}
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white overflow-hidden shadow-xl shadow-slate-200/50 flex flex-col">
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-[0.2em]">Últimas Operaciones Recibidas</h3>
|
||||
<button className="text-[10px] font-black text-blue-600 uppercase tracking-widest hover:text-blue-700 flex items-center gap-1">
|
||||
Ver todas <ArrowUpRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/30 text-[9px] uppercase text-slate-400 font-black tracking-[0.15em] border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-8 py-4">Aviso / Detalle</th>
|
||||
<th className="px-8 py-4 text-center">Horario (GMT-3)</th>
|
||||
<th className="px-8 py-4 text-center">Estado</th>
|
||||
<th className="px-8 py-4 text-right">Monto Neto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{recentAds.map((ad) => (
|
||||
<tr key={ad.id} className="hover:bg-blue-50/30 transition-colors group">
|
||||
<td className="px-8 py-4">
|
||||
<div className="font-extrabold text-slate-900 uppercase text-xs tracking-tight group-hover:text-blue-600 transition-colors">{ad.title}</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter mt-0.5">
|
||||
{ad.categoryName} • ID #{ad.id.toString().padStart(6, '0')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<div className="inline-flex items-center gap-1.5 text-slate-800 font-black bg-slate-100 px-2.5 py-1 rounded-lg text-[10px]">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{formatLocalTime(ad.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center rounded-md px-2.5 py-1 text-[8px] font-black uppercase tracking-tighter border",
|
||||
ad.status === 'Published'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-100'
|
||||
)}>
|
||||
{ad.status === 'Published' ? 'Cobrado' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-40 mr-1">$</span>
|
||||
{formatCurrency(ad.adFee)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// StatsCard actualizado con estética Premium
|
||||
function StatsCard({ title, value, icon: Icon, color, delay }: any) {
|
||||
const colorMap: any = {
|
||||
blue: 'text-blue-600 bg-blue-50 border-blue-100',
|
||||
emerald: 'text-emerald-600 bg-emerald-50 border-emerald-100',
|
||||
purple: 'text-purple-600 bg-purple-50 border-purple-100',
|
||||
amber: 'text-amber-600 bg-amber-50 border-amber-100',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
whileHover={{ y: -3 }}
|
||||
className="relative overflow-hidden rounded-[2rem] border border-slate-200 bg-white p-6 shadow-lg shadow-slate-200/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1.5">{title}</p>
|
||||
<h3 className="text-2xl font-black tracking-tighter text-slate-900">{value}</h3>
|
||||
</div>
|
||||
<div className={clsx("flex h-11 w-11 items-center justify-center rounded-2xl border shadow-inner", colorMap[color])}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -right-4 h-16 w-16 rounded-full bg-slate-50 opacity-50" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
312
frontend/counter-panel/src/pages/AdvancedAnalytics.tsx
Normal file
312
frontend/counter-panel/src/pages/AdvancedAnalytics.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import { dashboardService } from '../services/dashboardService';
|
||||
import type {
|
||||
AdvancedAnalyticsData,
|
||||
PaymentMethodStat,
|
||||
CategoryPerformanceStat
|
||||
} from '../types/Analytics';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, AreaChart, Area
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, DollarSign,
|
||||
Clock, ArrowUpRight, ArrowDownRight, Award, PieChart as PieChartIcon
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Colores para gráficos con estética premium
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981'];
|
||||
|
||||
export default function AdvancedAnalytics() {
|
||||
const [data, setData] = useState<AdvancedAnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState('month');
|
||||
|
||||
const loadAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const end = new Date().toISOString();
|
||||
const start = new Date();
|
||||
if (timeRange === 'week') start.setDate(start.getDate() - 7);
|
||||
else if (timeRange === 'month') start.setMonth(start.getMonth() - 1);
|
||||
else if (timeRange === 'year') start.setFullYear(start.getFullYear() - 1);
|
||||
|
||||
const res = await dashboardService.getAdvancedAnalytics(start.toISOString(), end);
|
||||
setData(res);
|
||||
} catch (error) {
|
||||
console.error('Error al cargar analítica:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, [loadAnalytics]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs text-center">Sincronizando con el servidor central...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-700">
|
||||
{/* Encabezado con selector de rango temporal */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-slate-900 tracking-tighter uppercase flex items-center gap-3">
|
||||
<span className="bg-blue-600 w-2 h-8 rounded-full"></span>
|
||||
BI & Analítica Avanzada
|
||||
</h1>
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-[10px] mt-1">Inteligencia de negocios y reportes gerenciales</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-1 rounded-2xl shadow-xl border border-slate-100 flex gap-1">
|
||||
{['week', 'month', 'year'].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setTimeRange(r)}
|
||||
className={`px-6 py-2 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${timeRange === r ? 'bg-slate-900 text-white shadow-lg' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{r === 'week' ? '7 Días' : r === 'month' ? '30 Días' : 'Año'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjetas de KPIs Principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<KPICard
|
||||
title="Recaudación Neto"
|
||||
value={`$${data.totalRevenue?.toLocaleString() || 0}`}
|
||||
growth={data.revenueGrowth}
|
||||
icon={<DollarSign className="text-blue-600" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Avisos Publicados"
|
||||
value={data.totalAds}
|
||||
growth={data.adsGrowth}
|
||||
icon={<Award className="text-emerald-600" />}
|
||||
color="emerald"
|
||||
/>
|
||||
<KPICard
|
||||
title="Promedio por Aviso"
|
||||
value={`$${((data.totalRevenue || 0) / (data.totalAds || 1)).toLocaleString()}`}
|
||||
icon={<TrendingUp className="text-indigo-600" />}
|
||||
color="indigo"
|
||||
simple
|
||||
/>
|
||||
<KPICard
|
||||
title="Crecimiento vs Ant."
|
||||
value={`${data.revenueGrowth?.toFixed(1) || 0}%`}
|
||||
icon={<PieChartIcon className="text-amber-600" />}
|
||||
color="amber"
|
||||
simple
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Gráfico de Línea: Tendencia Temporal */}
|
||||
<div className="lg:col-span-8 bg-white p-8 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-8">Tendencia de Ingresos Diarios</h3>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.dailyTrends}>
|
||||
<defs>
|
||||
<linearGradient id="colorAmountArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)' }}
|
||||
labelStyle={{ fontWeight: 'bold', color: '#1e293b' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="amount" stroke="#3b82f6" strokeWidth={4} fillOpacity={1} fill="url(#colorAmountArea)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de Torta: Canales de Cobro */}
|
||||
<div className="lg:col-span-4 bg-white p-8 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-8">Manejo de Tesorería</h3>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.paymentsDistribution}
|
||||
innerRadius={70}
|
||||
outerRadius={100}
|
||||
paddingAngle={8}
|
||||
dataKey="total"
|
||||
nameKey="method"
|
||||
>
|
||||
{data.paymentsDistribution?.map((_: PaymentMethodStat, index: number) => (
|
||||
<Cell key={`cell-analytics-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 mt-4 px-4 overflow-y-auto max-h-[100px]">
|
||||
{data.paymentsDistribution?.map((p: PaymentMethodStat, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: COLORS[idx % COLORS.length] }}></div>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase">{p.method}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-slate-900">${p.total?.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentro del return de AdvancedAnalytics.tsx */}
|
||||
<div className="bg-white p-8 rounded-[3rem] shadow-xl border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">Eficiencia de Canales</h3>
|
||||
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg">Real-time</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<ChannelProgress
|
||||
label="Mostrador (Venta Presencial)"
|
||||
value={data.sourceMix?.mostradorPercent || 0}
|
||||
count={data.sourceMix?.mostradorCount || 0}
|
||||
color="bg-slate-900"
|
||||
/>
|
||||
<ChannelProgress
|
||||
label="Web (Wizard Digital)"
|
||||
value={data.sourceMix?.webPercent || 0}
|
||||
count={data.sourceMix?.webCount || 0}
|
||||
color="bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Gráfico de Barras: Horas Pico */}
|
||||
<div className="bg-slate-900 p-10 rounded-[3rem] text-white shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full -mr-32 -mt-32 blur-[80px]"></div>
|
||||
<h3 className="text-lg font-black uppercase tracking-tight mb-10 flex items-center gap-3 relative z-10">
|
||||
<Clock size={20} className="text-blue-400" />
|
||||
Frecuencia de Operatividad (Horas Pico)
|
||||
</h3>
|
||||
<div className="h-[280px] relative z-10">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.hourlyActivity}>
|
||||
<XAxis dataKey="hour" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10 }} />
|
||||
<Tooltip cursor={{ fill: '#ffffff10' }} contentStyle={{ color: '#000', borderRadius: '12px', border: 'none' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listado de Rubros: Participación en Ingresos */}
|
||||
<div className="bg-white p-10 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-10">Ranking de Rubros por Ingresos</h3>
|
||||
<div className="space-y-6">
|
||||
{data.categoryPerformance?.slice(0, 5).map((cp: CategoryPerformanceStat, idx: number) => (
|
||||
<div key={idx} className="group cursor-default">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-[10px] font-black text-slate-900 uppercase tracking-widest">{cp.categoryName}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-black text-blue-600 block leading-none">${cp.revenue?.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-slate-50 rounded-full overflow-hidden border border-slate-100">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${cp.share || 0}%` }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-indigo-600 rounded-full"
|
||||
></motion.div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 opacity-60">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase">{cp.adsCount} avisos</span>
|
||||
<span className="text-[9px] font-black text-indigo-500 uppercase">{cp.share?.toFixed(1)}% del total</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KPICardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
growth?: number;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
simple?: boolean;
|
||||
}
|
||||
|
||||
function ChannelProgress({ label, value, count, color }: any) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-end text-[10px] font-black uppercase mb-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="text-slate-900 text-xs">{count} avisos</span>
|
||||
</div>
|
||||
<span className="text-blue-600 text-lg tracking-tighter">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${value}%` }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className={clsx("h-full shadow-lg", color)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subcomponente reutilizable para tarjetas de métricas (KPIs)
|
||||
function KPICard({ title, value, growth, icon, color, simple = false }: KPICardProps) {
|
||||
const isPositive = (growth ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden"
|
||||
>
|
||||
<div className={`absolute top-0 right-0 w-20 h-20 bg-${color}-500/5 rounded-full -mr-10 -mt-10`}></div>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
{!simple && growth !== undefined && (
|
||||
<div className={`flex items-center gap-1 text-[9px] font-black uppercase px-2 py-1 rounded-lg ${isPositive ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{isPositive ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
{Math.abs(growth).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 leading-none">{title}</h4>
|
||||
<div className="text-3xl font-black text-slate-900 tracking-tighter leading-none">{value}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,295 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cashRegisterService } from '../services/cashRegisterService';
|
||||
import { Printer, Download, Clock, CheckCircle, RefreshCw } from 'lucide-react';
|
||||
import type { GlobalReport, ReportItem } from '../types/Report';
|
||||
import {
|
||||
Printer,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowRightLeft
|
||||
} from 'lucide-react';
|
||||
import CashClosingModal from '../components/CashClosingModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
|
||||
export default function CashRegisterPage() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const { showToast } = useToast();
|
||||
const [data, setData] = useState<GlobalReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showClosingModal, setShowClosingModal] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async (isManual = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await cashRegisterService.getDailyStatus();
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isManual) showToast('Datos de caja sincronizados correctamente.', 'success');
|
||||
} catch {
|
||||
showToast('Error al conectar con el servidor de tesorería.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
}, [loadData]);
|
||||
|
||||
const handleCerrarCaja = async () => {
|
||||
if (!confirm("¿Desea finalizar el turno y descargar el comprobante de cierre?")) return;
|
||||
try {
|
||||
await cashRegisterService.downloadClosurePdf();
|
||||
alert("Cierre generado con éxito. Entregue el reporte junto con el efectivo.");
|
||||
} catch (e) {
|
||||
alert("Error al generar el PDF");
|
||||
}
|
||||
const handleCerrarCaja = () => {
|
||||
setShowClosingModal(true);
|
||||
};
|
||||
|
||||
if (loading && !data) return <div className="p-10 text-center text-gray-500">Sincronizando caja...</div>;
|
||||
const handleClosingComplete = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
val.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// SOLUCIÓN AL HORARIO: Forzar interpretación UTC
|
||||
const formatLocalTime = (dateString: string) => {
|
||||
if (!dateString) return "--:--";
|
||||
|
||||
// 1. Convertimos el formato de SQL (espacio) a ISO (T)
|
||||
let isoStr = dateString.replace(" ", "T");
|
||||
|
||||
// 2. Si no tiene la 'Z' al final, se la agregamos para decirle al navegador: "Esto es UTC"
|
||||
if (!isoStr.endsWith("Z")) {
|
||||
isoStr += "Z";
|
||||
}
|
||||
|
||||
const date = new Date(isoStr);
|
||||
|
||||
return date.toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: 'America/Argentina/Buenos_Aires' // Forzamos la zona de Argentina
|
||||
});
|
||||
};
|
||||
|
||||
const filteredItems = data?.items.filter((item: ReportItem) =>
|
||||
item.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) || [];
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-slate-500">
|
||||
<RefreshCw size={32} className="animate-spin text-blue-600" />
|
||||
<span className="font-black tracking-[0.2em] text-[10px] uppercase">Sincronizando flujos...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full flex flex-col gap-6 bg-gray-100 h-full overflow-y-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-slate-800 tracking-tight">MI CAJA DIARIA</h2>
|
||||
<p className="text-gray-500 text-sm flex items-center gap-1">
|
||||
<Clock size={14} /> Turno actual: {new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={loadData} className="p-2 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
||||
<div className="p-5 w-full flex flex-col gap-5 bg-slate-50/50 h-full overflow-hidden">
|
||||
|
||||
{/* Header Section Refinado */}
|
||||
<div className="flex justify-between items-start">
|
||||
<motion.div initial={{ opacity: 0, x: -15 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Control de Tesorería</span>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-1.5 h-5 rounded-full"></span>
|
||||
Caja Diaria
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-white border border-slate-200 rounded-lg text-[9px] font-black text-slate-500 shadow-sm uppercase">
|
||||
<Clock size={10} className="text-blue-500" />
|
||||
TURNO: {new Date().toLocaleDateString('es-AR', { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, x: 15 }} animate={{ opacity: 1, x: 0 }} className="flex gap-3">
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
className="p-2 bg-white border border-slate-200 text-slate-500 hover:text-blue-600 hover:border-blue-200 rounded-xl transition-all shadow-sm active:scale-95"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCerrarCaja}
|
||||
className="bg-gray-900 text-white px-6 py-2 rounded-lg font-bold flex items-center gap-2 hover:bg-black transition-all shadow-lg active:scale-95"
|
||||
className="bg-slate-900 text-white px-5 py-2 rounded-xl font-black text-[10px] flex items-center gap-2 hover:bg-black transition-all shadow-lg shadow-slate-200 active:scale-95 group tracking-widest"
|
||||
>
|
||||
<Printer size={20} /> FINALIZAR Y CERRAR (F4)
|
||||
<Printer size={14} className="text-blue-400" />
|
||||
CERRAR CAJA (F4)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* KPIs DE CAJA */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-green-500 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Efectivo a Rendir</div>
|
||||
<div className="text-4xl font-black text-slate-800 mt-1">$ {data?.totalRevenue?.toLocaleString('es-AR', { minimumFractionDigits: 2 })}</div>
|
||||
{/* KPI Section Refinado (Más compacto) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<KPICard
|
||||
icon={<Banknote size={18} />}
|
||||
label="Recaudación Turno"
|
||||
value={`$ ${formatCurrency(data?.totalRevenue || 0)}`}
|
||||
badge="EN EFECTIVO"
|
||||
color="emerald"
|
||||
/>
|
||||
<KPICard
|
||||
icon={<CheckCircle size={18} />}
|
||||
label="Avisos Procesados"
|
||||
value={data?.totalAds || 0}
|
||||
badge="OPERACIONES"
|
||||
color="blue"
|
||||
/>
|
||||
<motion.div
|
||||
whileHover={{ y: -3 }}
|
||||
className="bg-slate-900 p-4 rounded-[1.5rem] shadow-xl flex flex-col justify-between text-white overflow-hidden relative border-b-4 border-blue-600"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-blue-600/10 rounded-full -mr-8 -mt-8 blur-2xl"></div>
|
||||
<div className="flex justify-between items-start mb-2 relative z-10">
|
||||
<div className="p-2 bg-white/5 rounded-xl text-blue-400"><Wallet size={16} /></div>
|
||||
<span className="text-[8px] font-black text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded-md uppercase tracking-widest">PRÓXIMO CIERRE</span>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-full text-green-600">
|
||||
<Download size={32} />
|
||||
<div className="relative z-10">
|
||||
<div className="text-slate-500 text-[8px] font-black uppercase tracking-widest mb-0.5">Arqueo Estimado</div>
|
||||
<div className="text-2xl font-mono font-black text-white leading-none">
|
||||
$ {formatCurrency(data?.totalRevenue || 0)}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 opacity-20">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="h-0.5 flex-1 bg-white rounded-full"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-blue-500 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Avisos Procesados</div>
|
||||
<div className="text-4xl font-black text-slate-800 mt-1">{data?.totalAds} <span className="text-lg text-gray-300">Clasificados</span></div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-full text-blue-600">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* TABLA DE MOVIMIENTOS */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col">
|
||||
<div className="p-4 bg-gray-50 border-b font-bold text-slate-600 text-sm uppercase tracking-wider">
|
||||
Detalle de transacciones del turno
|
||||
{/* Table Section (Contraste aumentado) */}
|
||||
<div className="flex-1 bg-white rounded-[1.8rem] shadow-xl shadow-slate-200/50 border border-slate-200 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative max-w-xs w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar movimiento..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-4 focus:ring-blue-500/5 outline-none transition-all placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-white border border-slate-200 rounded-xl text-[9px] font-black text-slate-600 hover:border-slate-400 transition-all shadow-sm uppercase tracking-tighter">
|
||||
<Filter size={12} /> Filtrar Pagos
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-widest text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Turno Activo
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-gray-50 text-gray-400 text-[10px] uppercase font-bold sticky top-0 z-10">
|
||||
<thead className="bg-slate-50 text-slate-600 text-[9px] uppercase font-black tracking-[0.15em] sticky top-0 z-10 backdrop-blur-md border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="p-4 border-b">ID</th>
|
||||
<th className="p-4 border-b">Hora</th>
|
||||
<th className="p-4 border-b">Aviso / Título</th>
|
||||
<th className="p-4 border-b">Rubro</th>
|
||||
<th className="p-4 border-b text-right">Monto</th>
|
||||
<th className="px-6 py-4">ID Operación</th>
|
||||
<th className="px-6 py-4 text-center">Horario (GMT-3)</th>
|
||||
<th className="px-6 py-4">Detalle del Aviso</th>
|
||||
<th className="px-6 py-4">Rubro</th>
|
||||
<th className="px-6 py-4 text-right">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-sm">
|
||||
{data?.items.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/50 transition-colors">
|
||||
<td className="p-4 font-mono text-gray-400">#{item.id}</td>
|
||||
<td className="p-4 text-gray-600">{new Date(item.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||
<td className="p-4 font-bold text-slate-700">{item.title}</td>
|
||||
<td className="p-4">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded text-[10px] font-bold text-gray-500 uppercase">{item.category}</span>
|
||||
</td>
|
||||
<td className="p-4 text-right font-black text-slate-900">$ {item.amount.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-20 text-center text-gray-400 italic">No se han registrado cobros en este turno todavía.</td>
|
||||
</tr>
|
||||
)}
|
||||
<tbody className="divide-y divide-slate-50 text-xs">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredItems.map((item: ReportItem, idx: number) => (
|
||||
<motion.tr
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.02 }}
|
||||
className="hover:bg-blue-50/40 transition-all group"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-slate-500 font-bold">#{item.id.toString().padStart(6, '0')}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="inline-flex items-center gap-1.5 text-slate-800 font-black bg-slate-100 px-2.5 py-1 rounded-lg">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{formatLocalTime(item.date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-extrabold text-slate-900 tracking-tight group-hover:text-blue-600 transition-colors uppercase line-clamp-1">{item.title}</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold tracking-tighter opacity-80 italic">{item.clientName || 'Consumidor Final'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="bg-slate-100 px-2.5 py-1 rounded-md text-[8px] font-black text-slate-700 uppercase tracking-tighter border border-slate-200">
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-40 mr-1">$</span>
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer de la tabla con el total */}
|
||||
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
|
||||
<span className="text-xs font-bold uppercase opacity-60">Total en Caja</span>
|
||||
<span className="text-xl font-mono font-bold text-green-400">$ {data?.totalRevenue?.toLocaleString()}</span>
|
||||
{/* Footer Dark Bar (Más compacta y alto contraste) */}
|
||||
<div className="px-6 py-4 bg-slate-900 border-t border-slate-800 flex justify-between items-center shadow-[0_-10px_30px_rgba(0,0,0,0.15)] flex-shrink-0">
|
||||
<div className="flex items-center gap-8">
|
||||
<SummaryItem icon={<Banknote size={14} />} label="Efectivo" value={formatCurrency(data?.totalCash || 0)} color="text-emerald-400" />
|
||||
<SummaryItem icon={<CreditCard size={14} />} label="Tarjetas" value={formatCurrency((data?.totalDebit || 0) + (data?.totalCredit || 0))} color="text-blue-400" />
|
||||
<SummaryItem icon={<ArrowRightLeft size={14} />} label="Transf." value={formatCurrency(data?.totalTransfer || 0)} color="text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[8px] font-black text-blue-500 uppercase tracking-[0.2em] mb-0.5">Cierre Parcial</span>
|
||||
<div className="text-xl font-mono font-black text-white flex items-baseline gap-1.5">
|
||||
<span className="text-[9px] font-sans font-bold text-white/30 uppercase tracking-widest">TOTAL</span>
|
||||
<span className="text-green-400">$ {formatCurrency(data?.totalRevenue || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showClosingModal && (
|
||||
<CashClosingModal onClose={() => setShowClosingModal(false)} onComplete={handleClosingComplete} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPICard({ icon, label, value, badge, color }: any) {
|
||||
const colorStyles: any = {
|
||||
emerald: "bg-emerald-500/10 text-emerald-600",
|
||||
blue: "bg-blue-500/10 text-blue-600"
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div whileHover={{ y: -3 }} className="bg-white p-4 rounded-[1.5rem] shadow-lg border border-slate-100 flex flex-col justify-between">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className={`p-2 rounded-xl ${colorStyles[color]}`}>{icon}</div>
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest ${colorStyles[color]}`}>{badge}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-600 text-[8px] font-black uppercase tracking-widest mb-0.5">{label}</div>
|
||||
<div className="text-2xl font-mono font-black text-slate-950 leading-none">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ icon, label, value, color }: any) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 bg-white/5 rounded-lg ${color}`}>{icon}</div>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[7px] font-black text-slate-500 uppercase tracking-tighter">{label}</span>
|
||||
<span className="text-xs font-mono font-bold text-white tracking-tighter">$ {value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
import {
|
||||
Printer, Save,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
Type, Search, ChevronDown, Bold, Square as FrameIcon
|
||||
Type, Search, ChevronDown, Bold, Square as FrameIcon,
|
||||
ArrowUpRight,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import { useCashSession } from '../hooks/useCashSession';
|
||||
import CashOpeningModal from '../components/CashOpeningModal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Interfaces
|
||||
interface Operation { id: number; name: string; }
|
||||
@@ -18,9 +26,22 @@ interface PricingResult {
|
||||
specialCharCount: number; details: string; appliedPromotion: string;
|
||||
}
|
||||
|
||||
const escapeHTML = (str: string) => {
|
||||
return str.replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[m] || m));
|
||||
};
|
||||
|
||||
export default function FastEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const { session, loading: sessionLoading, refreshSession } = useCashSession();
|
||||
|
||||
const [categorySearch, setCategorySearch] = useState("");
|
||||
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
||||
@@ -45,6 +66,8 @@ export default function FastEntryPage() {
|
||||
const clientWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const textInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const debouncedText = useDebounce(formData.text, 500);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
const filteredCategories = flatCategories.filter(cat =>
|
||||
cat.path.toLowerCase().includes(categorySearch.toLowerCase()) ||
|
||||
@@ -53,7 +76,17 @@ export default function FastEntryPage() {
|
||||
|
||||
const selectedCategoryName = flatCategories.find(c => c.id === parseInt(formData.categoryId))?.name;
|
||||
|
||||
const printCourtesyTicket = (data: any, priceInfo: PricingResult) => {
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, boolean> = {
|
||||
categoryId: !formData.categoryId,
|
||||
operationId: !formData.operationId,
|
||||
text: !formData.text || formData.text.trim().length === 0,
|
||||
};
|
||||
setErrors(newErrors);
|
||||
return !Object.values(newErrors).some(v => v);
|
||||
}, [formData]);
|
||||
|
||||
const printCourtesyTicket = (data: typeof formData, priceInfo: PricingResult) => {
|
||||
const printWindow = window.open('', '_blank', 'width=300,height=600');
|
||||
if (!printWindow) return;
|
||||
|
||||
@@ -66,12 +99,12 @@ export default function FastEntryPage() {
|
||||
<p>${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>CLIENTE:</b> ${data.clientName || 'Consumidor Final'}<br/>
|
||||
<b>RUBRO:</b> ${selectedCategoryName}<br/>
|
||||
<b>CLIENTE:</b> ${escapeHTML(data.clientName || 'Consumidor Final')}<br/>
|
||||
<b>RUBRO:</b> ${escapeHTML(selectedCategoryName || '')}<br/>
|
||||
<b>DÍAS:</b> ${data.days}<br/>
|
||||
</div>
|
||||
<div style="background: #f0f0f0; padding: 8px; margin: 10px 0; border: 1px solid #000;">
|
||||
${data.text.toUpperCase()}
|
||||
${escapeHTML(data.text).toUpperCase()}
|
||||
</div>
|
||||
<div style="text-align: right; border-top: 1px dashed #000; padding-top: 5px;">
|
||||
<b style="font-size: 16px;">TOTAL: $${priceInfo.totalPrice.toLocaleString()}</b>
|
||||
@@ -85,6 +118,78 @@ export default function FastEntryPage() {
|
||||
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
|
||||
};
|
||||
|
||||
const printPaymentReceipt = (data: typeof formData, priceInfo: PricingResult, payments: Payment[]) => {
|
||||
const printWindow = window.open('', '_blank', 'width=350,height=700');
|
||||
if (!printWindow) return;
|
||||
|
||||
const username = localStorage.getItem('username') || 'Cajero';
|
||||
const now = new Date();
|
||||
const totalWithSurcharges = payments.reduce((sum, p) => sum + p.amount + p.surcharge, 0);
|
||||
|
||||
const paymentRows = payments.map(p => {
|
||||
const paymentMethodMap: Record<string, string> = {
|
||||
'Cash': 'Efectivo',
|
||||
'Debit': 'Débito',
|
||||
'Credit': p.cardPlan || 'Crédito',
|
||||
'Transfer': 'Transferencia'
|
||||
};
|
||||
const methodName = paymentMethodMap[p.paymentMethod] || p.paymentMethod;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 5px 0; border-bottom: 1px solid #ddd;">${methodName}</td>
|
||||
<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd;">$${p.amount.toLocaleString()}</td>
|
||||
${p.surcharge > 0 ? `<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd; color: #f59e0b;">+$${p.surcharge.toLocaleString()}</td>` : '<td></td>'}
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<head><style>@media print { body { margin: 0; } }</style></head>
|
||||
<body style="font-family: 'Courier New', monospace; width: 320px; padding: 15px; font-size: 11px; background: white;">
|
||||
<div style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 10px;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: bold;">DIARIO EL DÍA</h1>
|
||||
<p style="margin: 2px 0; font-size: 10px;">Comprobante de Pago</p>
|
||||
<p style="margin: 2px 0; font-size: 10px; font-weight: bold;">N° ${Math.floor(Math.random() * 100000).toString().padStart(8, '0')}</p>
|
||||
<p style="margin: 5px 0; font-size: 9px;">${now.toLocaleDateString('es-AR')} - ${now.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p style="margin: 3px 0;"><b>CLIENTE:</b> ${escapeHTML(data.clientName || 'Consumidor Final')}</p>
|
||||
${data.clientDni ? `<p style="margin: 3px 0;"><b>DNI/CUIT:</b> ${escapeHTML(data.clientDni)}</p>` : ''}
|
||||
<p style="margin: 3px 0;"><b>ATENDIDO POR:</b> ${escapeHTML(username)}</p>
|
||||
</div>
|
||||
<div style="border-top: 1px dashed #000; border-bottom: 1px dashed #000; padding: 8px 0; margin-bottom: 10px;">
|
||||
<p style="margin: 3px 0; font-weight: bold;">DETALLE DEL SERVICIO:</p>
|
||||
<p style="margin: 3px 0;">Rubro: ${escapeHTML(selectedCategoryName || '')}</p>
|
||||
<p style="margin: 3px 0;">Duración: ${data.days} día${data.days > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<table style="width: 100%; font-size: 10px;">
|
||||
<tr><td>Tarifa Base</td><td style="text-align: right;">$${priceInfo.baseCost.toLocaleString()}</td></tr>
|
||||
${priceInfo.extraCost > 0 ? `<tr><td>Recargo texto</td><td style="text-align: right;">+$${priceInfo.extraCost.toLocaleString()}</td></tr>` : ''}
|
||||
<tr style="border-top: 2px solid #000; font-weight: bold; font-size: 12px;">
|
||||
<td style="padding: 8px 0;">SUBTOTAL</td><td style="text-align: right; padding: 8px 0;">$${priceInfo.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="border-top: 2px solid #000; padding-top: 10px; margin-bottom: 10px;">
|
||||
<p style="margin: 5px 0; font-weight: bold; text-align: center;">MEDIOS DE PAGO</p>
|
||||
<table style="width: 100%; font-size: 10px;">${paymentRows}</table>
|
||||
</div>
|
||||
<div style="background: #000; color: #fff; padding: 12px; text-align: center; border-radius: 5px;">
|
||||
<p style="margin: 0; font-size: 10px;">TOTAL A PAGAR</p>
|
||||
<p style="margin: 5px 0; font-size: 24px; font-weight: bold;">$${totalWithSurcharges.toLocaleString('es-AR', { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -96,16 +201,18 @@ export default function FastEntryPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validate()) return;
|
||||
setShowPaymentModal(true);
|
||||
}, [validate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F10') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
if (e.key === 'F10') { e.preventDefault(); handleSubmit(); }
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [formData, pricing, options]);
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -142,13 +249,7 @@ export default function FastEntryPage() {
|
||||
}
|
||||
}, [debouncedClientSearch, showSuggestions]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.categoryId || !formData.operationId || !formData.text) {
|
||||
alert("⚠️ Error: Complete Rubro, Operación y Texto.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`¿Confirmar cobro de $${pricing.totalPrice.toLocaleString()}?`)) return;
|
||||
|
||||
const handlePaymentConfirm = async (payments: Payment[]) => {
|
||||
try {
|
||||
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await api.post('/listings', {
|
||||
@@ -156,21 +257,30 @@ export default function FastEntryPage() {
|
||||
operationId: parseInt(formData.operationId),
|
||||
title: formData.text.substring(0, 40) + '...',
|
||||
description: formData.text,
|
||||
price: 0, adFee: pricing.totalPrice,
|
||||
price: 0,
|
||||
adFee: pricing.totalPrice,
|
||||
status: 'Published',
|
||||
printText: formData.text,
|
||||
printStartDate: tomorrow.toISOString(),
|
||||
printDaysCount: formData.days,
|
||||
isBold: options.isBold, isFrame: options.isFrame,
|
||||
printFontSize: options.fontSize, printAlignment: options.alignment,
|
||||
clientName: formData.clientName, clientDni: formData.clientDni
|
||||
isBold: options.isBold,
|
||||
isFrame: options.isFrame,
|
||||
printFontSize: options.fontSize,
|
||||
printAlignment: options.alignment,
|
||||
clientName: formData.clientName,
|
||||
clientDni: formData.clientDni,
|
||||
payments
|
||||
});
|
||||
|
||||
printCourtesyTicket(formData, pricing);
|
||||
setTimeout(() => { printPaymentReceipt(formData, pricing, payments); }, 500);
|
||||
setFormData({ ...formData, text: '', clientName: '', clientDni: '' });
|
||||
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
||||
alert('✅ Aviso procesado correctamente.');
|
||||
} catch (err) { alert('❌ Error al procesar el cobro.'); }
|
||||
setShowPaymentModal(false);
|
||||
setErrors({});
|
||||
showToast('Aviso procesado correctamente.', 'success');
|
||||
} catch (err) {
|
||||
showToast('Error al procesar el cobro.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectClient = (client: Client) => {
|
||||
@@ -178,249 +288,209 @@ export default function FastEntryPage() {
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-slate-50">
|
||||
<RefreshCw className="animate-spin text-blue-600" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-4 flex gap-4 bg-gray-100 overflow-hidden max-h-screen">
|
||||
<>
|
||||
{/* BLOQUEO DE SEGURIDAD: Si no hay sesión, mostramos el modal de apertura */}
|
||||
<AnimatePresence>
|
||||
{!session.isOpen && (
|
||||
<CashOpeningModal
|
||||
onSuccess={refreshSession}
|
||||
onCancel={() => navigate('/dashboard')} // Si cancela la apertura, lo sacamos de "Nuevo Aviso"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* PANEL IZQUIERDO: FORMULARIO */}
|
||||
<div className="flex-[7] bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col min-h-0">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-gray-100 pb-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-800 tracking-tight uppercase">Nueva Publicación</h2>
|
||||
<p className="text-[10px] text-slate-400 font-mono">ID TERMINAL: T-01 | CAJA: 01</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.99 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
// Si no hay sesión, aplicamos un filtro de desenfoque y desaturación
|
||||
filter: session.isOpen ? "blur(0px) grayscale(0)" : "blur(8px) grayscale(1)"
|
||||
}}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
className={clsx(
|
||||
"w-full h-full p-5 flex gap-5 bg-slate-50/50 overflow-hidden max-h-screen",
|
||||
// Bloqueamos interacciones físicas mientras el modal esté presente
|
||||
!session.isOpen && "pointer-events-none select-none opacity-40"
|
||||
)}
|
||||
>
|
||||
{/* PANEL IZQUIERDO: FORMULARIO */}
|
||||
<div className="flex-[7] bg-white rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-200 p-6 flex flex-col min-h-0 relative">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-3">
|
||||
<span className="bg-blue-600 w-1.5 h-6 rounded-full"></span>
|
||||
Nueva Publicación
|
||||
</h2>
|
||||
<p className="text-[9px] text-slate-500 font-bold tracking-[0.2em] mt-0.5 uppercase opacity-80">Caja 01 - Recepción de Avisos</p>
|
||||
</div>
|
||||
<div className="bg-slate-100 p-2 rounded-xl text-slate-400"><Printer size={18} /></div>
|
||||
</div>
|
||||
<Printer size={24} className="text-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="relative" ref={catWrapperRef}>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Rubro</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full p-3 border rounded-xl bg-gray-50 flex justify-between items-center cursor-pointer transition-all",
|
||||
isCatDropdownOpen ? "border-blue-500 ring-4 ring-blue-500/10" : "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
onClick={() => setIsCatDropdownOpen(!isCatDropdownOpen)}
|
||||
>
|
||||
<span className={clsx("font-bold text-sm", !formData.categoryId && "text-gray-400")}>
|
||||
{selectedCategoryName || "-- Seleccionar Rubro --"}
|
||||
</span>
|
||||
<ChevronDown size={18} className={clsx("text-gray-400 transition-transform", isCatDropdownOpen && "rotate-180")} />
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="relative" ref={catWrapperRef}>
|
||||
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.categoryId ? "text-rose-500" : "text-slate-500")}>
|
||||
Rubro Seleccionado {errors.categoryId && "• Requerido"}
|
||||
</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full p-3.5 border-2 rounded-xl flex justify-between items-center cursor-pointer transition-all duration-300",
|
||||
isCatDropdownOpen ? "border-blue-500 bg-blue-50/30" : errors.categoryId ? "border-rose-200 bg-rose-50/50" : "border-slate-100 bg-slate-50/50 hover:border-slate-300"
|
||||
)}
|
||||
onClick={() => setIsCatDropdownOpen(!isCatDropdownOpen)}
|
||||
>
|
||||
<span className={clsx("font-extrabold text-sm tracking-tight", !formData.categoryId ? "text-slate-400" : "text-slate-800")}>
|
||||
{selectedCategoryName || "BUSCAR RUBRO..."}
|
||||
</span>
|
||||
<ChevronDown size={18} className={clsx("transition-transform", isCatDropdownOpen ? "rotate-180 text-blue-500" : "text-slate-400")} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isCatDropdownOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-full left-0 right-0 bg-white border-2 border-slate-100 shadow-2xl rounded-2xl mt-2 z-[100] flex flex-col max-h-[350px] overflow-hidden">
|
||||
<div className="p-3 bg-slate-50 border-b flex items-center gap-2">
|
||||
<Search size={16} className="text-slate-400" />
|
||||
<input autoFocus type="text" placeholder="Filtrar por nombre o código..." className="bg-transparent w-full outline-none text-sm font-bold text-slate-700 placeholder:text-slate-400" value={categorySearch} onChange={(e) => setCategorySearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-2 px-1 custom-scrollbar">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat.id} className={clsx("mx-1 px-4 py-2.5 text-xs cursor-pointer rounded-lg transition-all mb-1 flex items-center gap-3", !cat.isSelectable ? "text-slate-300 italic opacity-60" : "hover:bg-blue-50 text-slate-600 font-bold", parseInt(formData.categoryId) === cat.id && "bg-blue-600 text-white shadow-lg pl-6")} style={{ marginLeft: `${(cat.level * 12) + 4}px` }} onClick={() => { if (cat.isSelectable) { setFormData({ ...formData, categoryId: cat.id.toString() }); setIsCatDropdownOpen(false); setErrors({ ...errors, categoryId: false }); } }}>
|
||||
<span className={clsx("w-1 h-1 rounded-full", parseInt(formData.categoryId) === cat.id ? "bg-white" : "bg-slate-300")}></span>
|
||||
{cat.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div>
|
||||
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.operationId ? "text-rose-500" : "text-slate-500")}>Operación {errors.operationId && "• Requerido"}</label>
|
||||
<select className={clsx("w-full p-3.5 border-2 rounded-xl outline-none font-extrabold text-sm tracking-tight transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cpath%20d%3D%22M5%207.5L10%2012.5L15%207.5%22%20stroke%3D%22%2364748B%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22/%3E%3C/svg%3E')] bg-[length:18px_18px] bg-[right_1rem_center] bg-no-repeat", formData.operationId ? "border-slate-100 bg-slate-50 text-slate-800" : errors.operationId ? "border-rose-200 bg-rose-50/50 text-rose-400" : "border-slate-100 bg-slate-50 text-slate-400")} value={formData.operationId} onChange={e => { setFormData({ ...formData, operationId: e.target.value }); setErrors({ ...errors, operationId: false }); }}>
|
||||
<option value="">ELIJA OPERACIÓN...</option>
|
||||
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCatDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl mt-2 z-[100] flex flex-col max-h-[300px] overflow-hidden">
|
||||
<div className="p-3 bg-gray-50 border-b flex items-center gap-2">
|
||||
<Search size={16} className="text-gray-400" />
|
||||
<input autoFocus type="text" placeholder="Filtrar rubros..." className="bg-transparent w-full outline-none text-sm font-medium"
|
||||
value={categorySearch} onChange={(e) => setCategorySearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-2">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat.id} className={clsx("px-4 py-2 text-sm cursor-pointer border-l-4 transition-all",
|
||||
!cat.isSelectable ? "text-gray-400 font-bold bg-gray-50/50 italic pointer-events-none" : "hover:bg-blue-50 border-transparent hover:border-blue-600",
|
||||
parseInt(formData.categoryId) === cat.id && "bg-blue-100 border-blue-600 font-bold"
|
||||
)}
|
||||
style={{ paddingLeft: `${(cat.level * 16) + 16}px` }}
|
||||
onClick={() => { setFormData({ ...formData, categoryId: cat.id.toString() }); setIsCatDropdownOpen(false); }}
|
||||
>
|
||||
{cat.name}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.text ? "text-rose-500" : "text-slate-500")}>Cuerpo del Aviso {errors.text && "• Requerido"}</label>
|
||||
<div className="relative flex-1 group">
|
||||
<textarea ref={textInputRef} className={clsx("w-full h-full p-6 border-2 rounded-[1.5rem] resize-none outline-none font-mono text-xl tracking-tighter leading-snug transition-all duration-300", errors.text ? "border-rose-200 bg-rose-50/30" : "border-slate-100 bg-slate-50/30 group-focus-within:border-blue-400 group-focus-within:bg-white text-slate-800")} placeholder="ESCRIBA EL TEXTO AQUÍ PARA IMPRENTA..." value={formData.text} onChange={e => { setFormData({ ...formData, text: e.target.value }); setErrors({ ...errors, text: false }); }}></textarea>
|
||||
<div className="absolute top-3 right-3"><div className="bg-white/80 backdrop-blur px-2 py-1 rounded-lg border border-slate-100 shadow-sm text-[9px] font-black text-slate-400 uppercase tracking-widest">F10 para Cobrar</div></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-3 bg-slate-900 px-5 py-2.5 rounded-xl shadow-lg">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col"><span className="text-[8px] text-slate-500 font-black uppercase">Palabras</span><span className={clsx("text-base font-mono font-black", pricing.wordCount > 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}</span></div>
|
||||
<div className="flex flex-col"><span className="text-[8px] text-slate-500 font-black uppercase">Signos Especiales</span><span className={clsx("text-base font-mono font-black", pricing.specialCharCount > 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}</span></div>
|
||||
</div>
|
||||
<div className="text-right"><span className="text-[9px] text-slate-400 font-bold italic uppercase tracking-tighter">Vista optimizada para diario</span><div className="flex gap-1 mt-0.5 justify-end">{[1, 2, 3, 4, 5].map(i => <div key={i} className={clsx("h-0.5 w-2.5 rounded-full", formData.text.length > i * 15 ? "bg-blue-500" : "bg-slate-800")}></div>)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 bg-slate-50/50 p-4 rounded-[1.5rem] border-2 border-slate-100">
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label>
|
||||
<div className="flex items-center bg-white rounded-lg border border-slate-200 overflow-hidden h-10">
|
||||
<button onClick={() => setFormData(f => ({ ...f, days: Math.max(1, f.days - 1) }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">-</button>
|
||||
<input type="number" className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm" value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
|
||||
<button onClick={() => setFormData(f => ({ ...f, days: f.days + 1 }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 relative" ref={clientWrapperRef}>
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
|
||||
<div className="relative h-10">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input type="text" className="w-full h-full pl-10 pr-4 bg-white border border-slate-200 rounded-lg outline-none focus:border-blue-500 font-extrabold text-xs tracking-tight placeholder:text-slate-400" placeholder="NOMBRE O RAZÓN SOCIAL..." value={formData.clientName} onFocus={() => setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} />
|
||||
</div>
|
||||
<AnimatePresence>{showSuggestions && clientSuggestions.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-full mb-2 left-0 right-0 bg-white border-2 border-slate-100 shadow-2xl rounded-xl overflow-hidden z-[110]">
|
||||
{clientSuggestions.map(client => (
|
||||
<div key={client.id} className="p-3 hover:bg-blue-50 cursor-pointer border-b border-slate-50 flex justify-between items-center group transition-colors" onClick={() => handleSelectClient(client)}>
|
||||
<div><div className="font-extrabold text-slate-800 text-[11px] uppercase group-hover:text-blue-600">{client.name}</div><div className="text-[9px] text-slate-500 font-mono mt-0.5">{client.dniOrCuit}</div></div>
|
||||
<ArrowUpRight size={12} className="text-slate-300 group-hover:text-blue-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}</AnimatePresence>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">DNI / CUIT</label>
|
||||
<input type="text" className="w-full h-10 bg-white border border-slate-200 rounded-lg font-mono text-center font-black text-xs outline-none focus:border-blue-500 placeholder:text-slate-400" placeholder="S/D" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Operación</label>
|
||||
<select className="w-full p-3 border border-gray-200 rounded-xl bg-gray-50 outline-none focus:border-blue-500 font-bold text-sm"
|
||||
value={formData.operationId} onChange={e => setFormData({ ...formData, operationId: e.target.value })}>
|
||||
<option value="">-- Operación --</option>
|
||||
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
{/* PANEL DERECHO */}
|
||||
<div className="flex-[3] flex flex-col gap-4 min-h-0 overflow-hidden">
|
||||
<div className="bg-slate-900 text-white rounded-[2rem] p-5 shadow-xl border-b-4 border-blue-600 flex-shrink-0">
|
||||
<div className="text-[9px] text-blue-400 uppercase tracking-widest mb-0.5 font-black">Total a Cobrar</div>
|
||||
<div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div>
|
||||
{pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>}
|
||||
{pricing.discount > 0 && <div className="mt-2 p-2 bg-green-500/10 rounded-lg text-green-400 flex flex-col border border-green-500/20"><div className="flex justify-between"><span>Descuento</span><span>-${pricing.discount.toLocaleString()}</span></div></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Cuerpo del Aviso (Texto para Imprenta)</label>
|
||||
<textarea
|
||||
ref={textInputRef}
|
||||
className="flex-1 w-full p-5 border border-gray-300 rounded-2xl resize-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none font-mono text-xl text-slate-700 leading-relaxed shadow-inner bg-gray-50/30"
|
||||
placeholder="ESCRIBA EL TEXTO AQUÍ..."
|
||||
value={formData.text}
|
||||
onChange={e => setFormData({ ...formData, text: e.target.value })}
|
||||
></textarea>
|
||||
<div className="flex justify-between items-center mt-3 bg-slate-50 p-2 rounded-lg border border-slate-100">
|
||||
<div className="flex gap-4 text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||
<span className={pricing.wordCount > 0 ? "text-blue-600" : ""}>Palabras: {pricing.wordCount}</span>
|
||||
{pricing.specialCharCount > 0 && <span className="text-orange-600">Signos: {pricing.specialCharCount}</span>}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-300 font-bold italic uppercase">Uso de mayúsculas recomendado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Días</label>
|
||||
<input type="number" className="w-full p-2.5 border border-gray-200 rounded-lg font-black text-center text-blue-600 outline-none"
|
||||
value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
|
||||
</div>
|
||||
<div className="col-span-2 relative" ref={clientWrapperRef}>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Cliente / Razón Social</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 text-gray-300" size={16} />
|
||||
<input type="text" className="w-full pl-9 p-2.5 border border-gray-200 rounded-lg outline-none focus:border-blue-500 font-bold text-sm"
|
||||
placeholder="Buscar o crear..." value={formData.clientName}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }}
|
||||
/>
|
||||
</div>
|
||||
{showSuggestions && clientSuggestions.length > 0 && (
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl overflow-hidden z-[110]">
|
||||
{clientSuggestions.map(client => (
|
||||
<div key={client.id} className="p-3 hover:bg-blue-50 cursor-pointer border-b border-gray-50 flex justify-between items-center transition-colors"
|
||||
onClick={() => handleSelectClient(client)}>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800 text-xs">{client.name}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">{client.dniOrCuit}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col gap-4 pr-1 custom-scrollbar">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-2.5 flex flex-col gap-2 shadow-sm flex-shrink-0">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
{['left', 'center', 'right', 'justify'].map(align => (
|
||||
<button key={align} onClick={() => setOptions({ ...options, alignment: align })} className={clsx("p-1.5 rounded-md hover:bg-white transition-all", options.alignment === align && "bg-white text-blue-600 shadow-sm")}>
|
||||
{align === 'left' && <AlignLeft size={14} />}{align === 'center' && <AlignCenter size={14} />}{align === 'right' && <AlignRight size={14} />}{align === 'justify' && <AlignJustify size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">DNI / CUIT</label>
|
||||
<input type="text" className="w-full p-2.5 border border-gray-200 rounded-lg font-mono text-center font-bold text-sm"
|
||||
placeholder="Documento" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL DERECHO: TOTALES Y VISTA PREVIA */}
|
||||
<div className="flex-[3] flex flex-col gap-4 min-h-0 overflow-hidden">
|
||||
|
||||
{/* TOTALES (FIJO ARRIBA) */}
|
||||
<div className="bg-slate-900 text-white rounded-3xl p-6 shadow-xl border-b-4 border-blue-600 flex-shrink-0">
|
||||
<div className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-black">Total a Cobrar</div>
|
||||
<div className="text-5xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-xl mt-1.5 opacity-50">$</span>
|
||||
{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2 text-[11px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-400 italic">
|
||||
<span>Tarifa Base</span>
|
||||
<span className="text-white">${pricing.baseCost.toLocaleString()}</span>
|
||||
</div>
|
||||
{pricing.extraCost > 0 && (
|
||||
<div className="flex justify-between text-orange-400">
|
||||
<span>Recargos por texto</span>
|
||||
<span>+${pricing.extraCost.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{pricing.surcharges > 0 && (
|
||||
<div className="flex justify-between text-blue-400">
|
||||
<span>Estilos visuales</span>
|
||||
<span>+${pricing.surcharges.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{pricing.discount > 0 && (
|
||||
<div className="mt-2 p-2 bg-green-500/10 rounded-lg text-green-400 flex flex-col border border-green-500/20 animate-pulse">
|
||||
<div className="flex justify-between">
|
||||
<span>Descuento Aplicado</span>
|
||||
<span>-${pricing.discount.toLocaleString()}</span>
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
{['small', 'normal', 'large'].map(size => (
|
||||
<button key={size} onClick={() => setOptions({ ...options, fontSize: size })} className={clsx("p-1.5 rounded-md hover:bg-white transition-all flex items-end", options.fontSize === size && "bg-white text-blue-600 shadow-sm")}>
|
||||
<Type size={size === 'small' ? 10 : size === 'normal' ? 14 : 18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] opacity-70 italic">{pricing.appliedPromotion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
<button onClick={() => setOptions({ ...options, isBold: !options.isBold })} className={clsx("p-2 rounded-lg border text-[9px] font-black transition-all flex items-center justify-center gap-2", options.isBold ? "bg-blue-600 border-blue-600 text-white" : "bg-slate-50 border-slate-200 text-slate-400 hover:bg-slate-100")}><Bold size={12} /> NEGRITA</button>
|
||||
<button onClick={() => setOptions({ ...options, isFrame: !options.isFrame })} className={clsx("p-2 rounded-lg border text-[9px] font-black transition-all flex items-center justify-center gap-2", options.isFrame ? "bg-slate-800 border-slate-800 text-white" : "bg-slate-50 border-slate-200 text-slate-400 hover:bg-slate-100")}><FrameIcon size={12} /> RECUADRO</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#fffef5] border border-yellow-200 rounded-2xl p-4 shadow-sm min-h-[140px] h-auto flex flex-col relative group flex-shrink-0">
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-yellow-400 opacity-20"></div>
|
||||
<h3 className="text-[8px] font-black text-yellow-700/60 uppercase mb-3 flex items-center gap-1.5 tracking-widest"><Printer size={10} /> Previsualización Real</h3>
|
||||
<div className="p-1">
|
||||
<div className={clsx("w-full leading-tight whitespace-pre-wrap break-words transition-all duration-300", options.isBold ? "font-bold text-slate-900" : "font-medium text-slate-700", options.isFrame ? "border-2 border-slate-900 p-3 bg-white shadow-sm" : "border-none", options.fontSize === 'small' ? 'text-[11px]' : options.fontSize === 'large' ? 'text-base' : 'text-xs', options.alignment === 'center' ? 'text-center' : options.alignment === 'right' ? 'text-right' : options.alignment === 'justify' ? 'text-justify' : 'text-left')}>
|
||||
{formData.text || "(Aviso vacío)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-2xl font-black shadow-xl flex flex-col items-center justify-center transition-all active:scale-95 group overflow-hidden flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-lg relative z-10"><Save size={20} /> COBRAR E IMPRIMIR</div>
|
||||
<span className="text-[8px] opacity-60 tracking-[0.3em] relative z-10 font-mono mt-0.5">SHORTCUT: F10</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CONTENEDOR CENTRAL SCROLLABLE (Toolbar + Preview) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col gap-4 pr-1 custom-scrollbar">
|
||||
|
||||
{/* TOOLBAR ESTILOS */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-2 flex flex-col gap-2 shadow-sm flex-shrink-0">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
|
||||
{['left', 'center', 'right', 'justify'].map(align => (
|
||||
<button key={align} onClick={() => setOptions({ ...options, alignment: align })}
|
||||
className={clsx("p-1.5 rounded-md hover:bg-white transition-all shadow-sm", options.alignment === align && "bg-white text-blue-600")}>
|
||||
{align === 'left' && <AlignLeft size={16} />}
|
||||
{align === 'center' && <AlignCenter size={16} />}
|
||||
{align === 'right' && <AlignRight size={16} />}
|
||||
{align === 'justify' && <AlignJustify size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
|
||||
{['small', 'normal', 'large'].map(size => (
|
||||
<button key={size} onClick={() => setOptions({ ...options, fontSize: size })}
|
||||
className={clsx("p-1.5 rounded-md hover:bg-white transition-all flex items-end", options.fontSize === size && "bg-white shadow-sm text-blue-600")}>
|
||||
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions({ ...options, isBold: !options.isBold })}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
|
||||
options.isBold ? "bg-blue-600 border-blue-600 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<Bold size={14} /> NEGRITA
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions({ ...options, isFrame: !options.isFrame })}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
|
||||
options.isFrame ? "bg-slate-800 border-slate-800 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<FrameIcon size={14} /> RECUADRO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VISTA PREVIA (DINÁMICA AL ALTO DEL TEXTO) */}
|
||||
<div className="bg-[#fffef5] border border-yellow-200 rounded-2xl p-5 shadow-sm min-h-[180px] h-auto flex flex-col relative group flex-shrink-0">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-yellow-400 opacity-20"></div>
|
||||
<h3 className="text-[9px] font-black text-yellow-700 uppercase mb-4 flex items-center gap-1.5 opacity-40 tracking-widest">
|
||||
<Printer size={12} /> Previsualización Real
|
||||
</h3>
|
||||
<div className="p-2">
|
||||
<div className={clsx(
|
||||
"w-full leading-tight whitespace-pre-wrap break-words transition-all duration-300",
|
||||
options.isBold ? "font-bold text-gray-900" : "font-medium text-gray-700",
|
||||
options.isFrame ? "border-2 border-gray-900 p-4 bg-white shadow-md" : "border-none",
|
||||
options.fontSize === 'small' ? 'text-xs' : options.fontSize === 'large' ? 'text-lg' : 'text-sm',
|
||||
options.alignment === 'center' ? 'text-center' : options.alignment === 'right' ? 'text-right' : options.alignment === 'justify' ? 'text-justify' : 'text-left'
|
||||
)}>
|
||||
{formData.text || "(Aviso vacío)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACCIÓN PRINCIPAL (FIJO ABAJO) */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white py-5 rounded-2xl font-black shadow-2xl flex flex-col items-center justify-center gap-0.5 transition-all active:scale-95 group relative overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
||||
<div className="flex items-center gap-3 text-xl relative z-10">
|
||||
<Save size={24} /> COBRAR E IMPRIMIR
|
||||
</div>
|
||||
<span className="text-[9px] opacity-60 tracking-[0.3em] relative z-10 font-mono">SHORTCUT: F10</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showPaymentModal && (
|
||||
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
524
frontend/counter-panel/src/pages/HistoryPage.tsx
Normal file
524
frontend/counter-panel/src/pages/HistoryPage.tsx
Normal file
@@ -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<any[]>([]);
|
||||
const [cajeros, setCajeros] = useState<any[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [showClaimModal, setShowClaimModal] = useState(false);
|
||||
const [claims, setClaims] = useState<any[]>([]);
|
||||
const [resolvingClaim, setResolvingClaim] = useState<any>(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 `
|
||||
<tr>
|
||||
<td style="padding: 5px 0; border-bottom: 1px solid #ddd;">${methodName}</td>
|
||||
<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd;">$${p.amount.toLocaleString()}</td>
|
||||
${p.surcharge > 0 ? `<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd; color: #f59e0b;">+$${p.surcharge.toLocaleString()}</td>` : '<td></td>'}
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<body style="font-family: 'Courier New', monospace; width: 300px; padding: 10px; font-size: 11px;">
|
||||
<div style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 10px;">
|
||||
<h1 style="margin: 0; font-size: 18px;">DIARIO EL DÍA</h1>
|
||||
<p style="margin: 2px 0; font-weight: bold; background: #000; color: #fff; display: inline-block; padding: 2px 10px;">*** DUPLICADO ***</p>
|
||||
<p style="margin: 5px 0; font-size: 9px;">Original: ${new Date(listing.createdAt).toLocaleString('es-AR')}</p>
|
||||
<p style="margin: 2px 0; font-size: 9px;">Re-impresión: ${now.toLocaleString('es-AR')}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<b>AVISO ID:</b> #${listing.id.toString().padStart(6, '0')}<br/>
|
||||
<b>RUBRO:</b> ${escapeHTML(listing.categoryName)}<br/>
|
||||
<b>ESTADO:</b> ${listing.status}<br/>
|
||||
</div>
|
||||
<div style="border-top: 1px dashed #000; border-bottom: 1px dashed #000; padding: 8px 0; margin-bottom: 10px;">
|
||||
<b>CUERPO DEL AVISO:</b><br/>
|
||||
<p style="text-transform: uppercase; line-height: 1.4;">${escapeHTML(listing.description)}</p>
|
||||
</div>
|
||||
<table style="width: 100%; font-size: 10px; margin-bottom: 10px;">
|
||||
${paymentRows}
|
||||
</table>
|
||||
<div style="background: #000; color: #fff; padding: 10px; text-align: center; font-size: 18px; font-weight: bold;">
|
||||
TOTAL: $${totalWithSurcharges.toLocaleString()}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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 (
|
||||
<div className="p-6 flex flex-col gap-6 h-full bg-[#f8fafc] overflow-hidden">
|
||||
|
||||
{/* FILTROS */}
|
||||
<div className="bg-white rounded-[2rem] p-6 shadow-xl border border-slate-200 flex-shrink-0">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Auditoría & Consultas</span>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-2">
|
||||
<span className="bg-slate-900 w-1.5 h-5 rounded-full"></span>
|
||||
Historial de Operaciones
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-black text-[10px] uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Clock className="animate-spin" size={14} /> : <Filter size={14} />}
|
||||
Actualizar Vista
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Desde</label>
|
||||
<input type="date" value={filters.from} onChange={e => 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" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Hasta</label>
|
||||
<input type="date" value={filters.to} onChange={e => 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" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Origen del Aviso</label>
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
{['All', 'Mostrador', 'Web'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilters({ ...filters, source: s })}
|
||||
className={clsx(
|
||||
"flex-1 py-2 text-[9px] font-black uppercase rounded-lg transition-all",
|
||||
filters.source === s ? "bg-white text-blue-600 shadow-sm" : "text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
{s === 'All' ? 'Todos' : s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Cajero Responsable</label>
|
||||
<select
|
||||
value={filters.userId}
|
||||
onChange={e => setFilters({ ...filters, userId: 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 appearance-none"
|
||||
>
|
||||
<option value="">TODOS LOS CAJEROS</option>
|
||||
{cajeros.map((u) => (
|
||||
<option key={u.id || u.Id} value={u.id || u.Id}>
|
||||
{(u.username || u.Username || 'Sin Nombre').toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Búsqueda Directa</label>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="Título o aviso..." value={filters.query} onChange={e => 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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLA */}
|
||||
<div className="flex-1 bg-white rounded-[2rem] shadow-xl border border-slate-200 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-500 text-[9px] uppercase font-black tracking-[0.15em] sticky top-0 z-10 border-b border-slate-100 backdrop-blur-md">
|
||||
<tr>
|
||||
<th className="px-8 py-4">Operación / Fecha</th>
|
||||
<th className="px-8 py-4">Detalle del Aviso</th>
|
||||
<th className="px-8 py-4 text-center">Cajero</th>
|
||||
<th className="px-8 py-4 text-right">Monto Total</th>
|
||||
<th className="px-8 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items
|
||||
.filter(i => (filters.source === 'All' || i.source === filters.source))
|
||||
.filter(i => i.title.toLowerCase().includes(filters.query.toLowerCase()))
|
||||
.map((item) => (
|
||||
<motion.tr
|
||||
key={item.id}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
className="hover:bg-blue-50/40 transition-all group"
|
||||
>
|
||||
<td className="px-8 py-4">
|
||||
{/* Badge de Origen */}
|
||||
<div className={clsx(
|
||||
"inline-flex px-2 py-0.5 rounded text-[8px] font-black uppercase mb-2",
|
||||
item.source === 'Web' ? "bg-blue-600 text-white shadow-lg shadow-blue-200" : "bg-slate-200 text-slate-600"
|
||||
)}>
|
||||
{item.source}
|
||||
</div>
|
||||
<div className="font-mono text-slate-400 font-bold text-[10px]">#{item.id.toString().padStart(6, '0')}</div>
|
||||
<div className="text-[10px] font-black text-slate-700 mt-1 flex items-center gap-1.5">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{new Date(item.date).toLocaleDateString('es-AR')} - {formatLocalTime(item.date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-extrabold text-slate-900 uppercase text-xs tracking-tight line-clamp-1 group-hover:text-blue-600 transition-colors">{item.title}</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter mt-0.5">{item.category}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-[9px] font-black text-slate-600 uppercase border border-slate-200">
|
||||
<UserIcon size={10} className="text-slate-400" /> {item.cashier || 'SISTEMA'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-30 mr-1">$</span>
|
||||
{item.amount.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleOpenDetail(item.id)}
|
||||
className="p-2.5 bg-slate-50 text-slate-400 hover:bg-blue-600 hover:text-white rounded-xl transition-all shadow-sm active:scale-90"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL LATERAL */}
|
||||
<AnimatePresence>
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 bg-slate-950/40 backdrop-blur-sm z-[200] flex justify-end">
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
className="bg-white w-full max-w-xl h-full shadow-2xl flex flex-col border-l border-slate-200"
|
||||
>
|
||||
{/* HEADER DEL PANEL */}
|
||||
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/30">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em]">Auditoría de Aviso</span>
|
||||
<h3 className="text-2xl font-black text-slate-900 tracking-tighter uppercase">#{selectedItem.listing.id.toString().padStart(6, '0')}</h3>
|
||||
</div>
|
||||
<button onClick={() => setSelectedItem(null)} className="p-3 hover:bg-slate-100 rounded-2xl transition-colors text-slate-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* BARRA DE NAVEGACIÓN DE PESTAÑAS (TABS) */}
|
||||
<div className="flex bg-white border-b border-slate-100 px-8 gap-8">
|
||||
<button
|
||||
onClick={() => setActiveDetailTab('info')}
|
||||
className={clsx(
|
||||
"py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b-2",
|
||||
activeDetailTab === 'info' ? "border-blue-600 text-blue-600" : "border-transparent text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
Ficha Técnica
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveDetailTab('claims')}
|
||||
className={clsx(
|
||||
"py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b-2 flex items-center gap-2",
|
||||
activeDetailTab === 'claims' ? "border-rose-500 text-rose-500" : "border-transparent text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
Incidencias
|
||||
{claims.length > 0 && (
|
||||
<span className={clsx(
|
||||
"px-1.5 py-0.5 rounded-md text-[9px]",
|
||||
activeDetailTab === 'claims' ? "bg-rose-500 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{claims.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CUERPO DEL PANEL CON SCROLL INDEPENDIENTE */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* PESTAÑA 1: INFORMACIÓN GENERAL */}
|
||||
{activeDetailTab === 'info' && (
|
||||
<motion.div
|
||||
key="info-tab" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-10"
|
||||
>
|
||||
{/* Status & Total */}
|
||||
<div className="bg-blue-600 text-white p-6 rounded-[2rem] shadow-xl shadow-blue-200 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20"><CheckCircle2 size={80} /></div>
|
||||
<div className="relative z-10">
|
||||
<span className="text-[10px] font-black uppercase opacity-60">Recaudación Total</span>
|
||||
<div className="text-4xl font-mono font-black">$ {selectedItem.listing.adFee?.toLocaleString('es-AR')}</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-[10px] font-bold uppercase bg-white/10 w-fit px-3 py-1 rounded-full border border-white/20">
|
||||
<ShieldCheck size={14} /> {selectedItem.listing.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Texto para Imprenta */}
|
||||
<section>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<FileText size={14} className="text-slate-500" /> Cuerpo de Publicación
|
||||
</h4>
|
||||
<div className="p-6 bg-slate-900 rounded-[1.5rem] text-slate-300 font-mono text-sm leading-relaxed uppercase">
|
||||
{selectedItem.listing.description}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Desglose de Pagos */}
|
||||
<section>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<Banknote size={14} className="text-emerald-500" /> Medios de Pago
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedItem.payments?.map((p: any, pIdx: number) => (
|
||||
<div key={pIdx} className="bg-slate-50 p-4 rounded-2xl border border-slate-100 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm text-slate-400">
|
||||
{p.paymentMethod === 'Cash' ? <Banknote size={18} /> : <CreditCard size={18} />}
|
||||
</div>
|
||||
<span className="font-black text-slate-700 uppercase text-[11px]">{p.paymentMethod} {p.cardPlan || ''}</span>
|
||||
</div>
|
||||
<span className="font-mono font-black text-slate-900">$ {p.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Datos Técnicos */}
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<InfoBox label="Rubro" value={selectedItem.listing.categoryName} />
|
||||
<InfoBox label="Duración" value={`${selectedItem.listing.printDaysCount} DÍAS`} />
|
||||
<InfoBox label="Fecha Op." value={new Date(selectedItem.listing.createdAt).toLocaleDateString()} />
|
||||
<InfoBox label="Responsable" value={selectedItem.listing.userId ? `ID #${selectedItem.listing.userId}` : 'Web'} />
|
||||
</section>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* PESTAÑA 2: INCIDENCIAS Y RECLAMOS */}
|
||||
{activeDetailTab === 'claims' && (
|
||||
<motion.div
|
||||
key="claims-tab" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[10px] font-black text-rose-500 uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldAlert size={14} /> Historial de Reclamos
|
||||
</h4>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase">Total: {claims.length} eventos</span>
|
||||
</div>
|
||||
|
||||
{claims.length === 0 ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-100 text-slate-300">
|
||||
<ShieldAlert size={40} className="mb-4 opacity-20" />
|
||||
<p className="font-black text-[10px] uppercase tracking-widest">Sin incidencias registradas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{claims.map((c: any) => (
|
||||
<div key={c.id} className={clsx(
|
||||
"p-6 rounded-[2rem] border-2 transition-all relative",
|
||||
c.status === 'Open' ? "bg-rose-50 border-rose-100 shadow-lg shadow-rose-100/50" : "bg-emerald-50 border-emerald-100"
|
||||
)}>
|
||||
{/* ... (Contenido del card de reclamo, igual que antes) ... */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-lg text-[9px] font-black uppercase tracking-tighter",
|
||||
c.status === 'Open' ? "bg-rose-500 text-white" : "bg-emerald-500 text-white"
|
||||
)}>
|
||||
{c.status === 'Open' ? 'Pendiente de Resolución' : 'Incidencia Resuelta'}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">{new Date(c.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-sm font-black text-slate-800 uppercase mb-2">{c.claimType}</p>
|
||||
<p className="text-xs text-slate-600 leading-relaxed bg-white/50 p-3 rounded-xl border border-black/5 italic mb-4">"{c.description}"</p>
|
||||
|
||||
{c.status === 'Open' ? (
|
||||
<button
|
||||
onClick={() => setResolvingClaim(c)}
|
||||
className="w-full py-3 bg-white border border-rose-200 text-rose-500 rounded-xl text-[10px] font-black uppercase hover:bg-rose-500 hover:text-white transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Brindar Solución
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 mt-4 pt-4 border-t border-black/5">
|
||||
{c.originalValues && (
|
||||
<div className="p-3 bg-slate-200/50 rounded-xl">
|
||||
<p className="text-[8px] font-black text-slate-400 uppercase mb-1">Snapshot Pre-ajuste:</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono leading-tight">{c.originalValues}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-white rounded-xl border border-emerald-200">
|
||||
<p className="text-[9px] font-black text-emerald-700 uppercase mb-1">Solución aplicada:</p>
|
||||
<p className="text-[11px] text-emerald-800 font-bold italic">"{c.solutionDescription}"</p>
|
||||
<p className="text-[8px] mt-2 text-emerald-600/50 font-black uppercase">Por: {c.resolvedByUsername} • {new Date(c.resolvedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* FOOTER GLOBAL (SIEMPRE VISIBLE) */}
|
||||
<div className="p-8 bg-slate-50 border-t border-slate-100 flex gap-4">
|
||||
<button onClick={handlePrintDuplicate} className="flex-1 bg-white border-2 border-slate-200 text-slate-600 font-black text-[10px] uppercase py-4 rounded-2xl hover:bg-slate-100 transition-all flex items-center justify-center gap-2">
|
||||
<Printer size={16} /> Duplicado Ticket
|
||||
</button>
|
||||
<button onClick={() => setShowClaimModal(true)} className="flex-1 bg-slate-900 text-white font-black text-[10px] uppercase py-4 rounded-2xl hover:bg-black transition-all flex items-center justify-center gap-2 shadow-lg">
|
||||
<MessageSquare size={16} className="text-blue-400" /> Abrir Reclamo
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* MODAL DE RECLAMO */}
|
||||
<AnimatePresence>
|
||||
{showClaimModal && selectedItem && (
|
||||
<ClaimModal
|
||||
listingId={selectedItem.listing.id}
|
||||
onClose={() => setShowClaimModal(false)}
|
||||
onSuccess={() => loadItemClaims(selectedItem.listing.id)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* MODAL DE RESOLUCION DE RECLAMO */}
|
||||
<AnimatePresence>
|
||||
{resolvingClaim && selectedItem && (
|
||||
<ResolveClaimModal
|
||||
claim={resolvingClaim}
|
||||
listing={selectedItem.listing}
|
||||
onClose={() => setResolvingClaim(null)}
|
||||
onSuccess={() => {
|
||||
loadItemClaims(selectedItem.listing.id);
|
||||
loadHistory();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ label, value, color = "text-slate-800" }: any) {
|
||||
return (
|
||||
<div className="p-3.5 bg-white border border-slate-100 rounded-xl shadow-sm">
|
||||
<span className="text-[8px] font-black text-slate-400 uppercase block mb-0.5 tracking-widest">{label}</span>
|
||||
<span className={clsx("text-[11px] font-black uppercase truncate block", color)}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
154
frontend/counter-panel/src/pages/TreasuryPage.tsx
Normal file
154
frontend/counter-panel/src/pages/TreasuryPage.tsx
Normal file
@@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSession, setSelectedSession] = useState<any>(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 <div className="p-20 text-center uppercase font-black text-xs text-slate-400 animate-pulse">Cargando Tesorería...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-8 bg-[#f8fafc] h-full">
|
||||
<header>
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em] mb-1 block">Administración Central</span>
|
||||
<h2 className="text-3xl font-black text-slate-900 tracking-tight uppercase">Validación de Cajas</h2>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
|
||||
{/* LISTADO DE CAJAS PENDIENTES */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Sesiones esperando cierre definitivo</h4>
|
||||
{pending.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2.5rem] border-2 border-dashed border-slate-200 text-center opacity-40">
|
||||
<CheckCircle2 size={48} className="mx-auto mb-4 text-emerald-500" />
|
||||
<p className="font-black text-xs uppercase tracking-widest">No hay cajas pendientes de validación</p>
|
||||
</div>
|
||||
) : (
|
||||
pending.map(s => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
whileHover={{ x: 5 }}
|
||||
onClick={() => 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"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center text-slate-400 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<UserIcon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-sm">{s.username}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1.5 mt-1">
|
||||
<Clock size={12} /> Cerrada: {new Date(s.closingDate).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-mono font-black text-slate-900">$ {(s.declaredCash + s.declaredCards + s.declaredTransfers).toLocaleString()}</p>
|
||||
<span className="text-[9px] font-black text-blue-600 bg-blue-50 px-2 py-1 rounded-md mt-1 inline-block">PENDIENTE LIQUIDAR</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PANEL DE ACCIÓN (DERECHA) */}
|
||||
<div className="lg:col-span-5">
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedSession ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }}
|
||||
className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5"><ShieldCheck size={120} /></div>
|
||||
<h3 className="text-xl font-black uppercase mb-8 flex items-center gap-3">
|
||||
<AlertCircle className="text-blue-400" /> Detalle de Liquidación
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 relative z-10">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TreasuryStat label="Efectivo Declarado" value={selectedSession.declaredCash} />
|
||||
<TreasuryStat label="Diferencia" value={selectedSession.totalDifference} color={selectedSession.totalDifference >= 0 ? "text-emerald-400" : "text-rose-400"} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-3xl p-6 border border-white/10 space-y-4">
|
||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest block">Observaciones de Tesorería</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Indique si el dinero coincide con el sobre entregado..."
|
||||
className="w-full bg-transparent border-none outline-none text-sm font-medium placeholder:opacity-20 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="w-full py-5 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-500/20 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} /> Validar y Archivar Caja
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="p-10 text-center text-slate-300 border-2 border-dashed border-slate-200 rounded-[2.5rem]">
|
||||
<p className="text-xs font-black uppercase tracking-widest">Seleccione una caja para auditar</p>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreasuryStat({ label, value, color = "text-white" }: any) {
|
||||
return (
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/10">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase block mb-1">{label}</span>
|
||||
<span className={clsx("text-lg font-mono font-black", color)}>$ {value.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
// Asegúrate de usar la variable de entorno o la URL correcta
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5176/api',
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
// 1. Interceptor de REQUEST: Pega el token
|
||||
|
||||
36
frontend/counter-panel/src/services/dashboardService.ts
Normal file
36
frontend/counter-panel/src/services/dashboardService.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const API_URL = `${import.meta.env.VITE_API_URL}/dashboard`;
|
||||
|
||||
export const dashboardService = {
|
||||
getStats: async (start?: string, end?: string) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.append('start', start);
|
||||
if (end) params.append('end', end);
|
||||
|
||||
const response = await fetch(`${API_URL}/stats?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al cargar estadísticas');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Obtiene analítica avanzada para reportes gerenciales detallados
|
||||
getAdvancedAnalytics: async (start?: string, end?: string) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.append('start', start);
|
||||
if (end) params.append('end', end);
|
||||
|
||||
const response = await fetch(`${API_URL}/analytics?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al cargar analítica avanzada');
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
40
frontend/counter-panel/src/types/Analytics.ts
Normal file
40
frontend/counter-panel/src/types/Analytics.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type PaymentMethodStat = {
|
||||
method: string;
|
||||
total: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type CategoryPerformanceStat = {
|
||||
categoryName: string;
|
||||
revenue: number;
|
||||
adsCount: number;
|
||||
share: number;
|
||||
};
|
||||
|
||||
export type HourlyStat = {
|
||||
hour: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DailyRevenue = {
|
||||
day: string;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type AdvancedAnalyticsData = {
|
||||
totalRevenue: number;
|
||||
totalAds: number;
|
||||
revenueGrowth: number;
|
||||
adsGrowth: number;
|
||||
paymentsDistribution: PaymentMethodStat[];
|
||||
categoryPerformance: CategoryPerformanceStat[];
|
||||
hourlyActivity: HourlyStat[];
|
||||
dailyTrends: DailyRevenue[];
|
||||
|
||||
sourceMix: {
|
||||
mostradorCount: number;
|
||||
webCount: number;
|
||||
mostradorPercent: number;
|
||||
webPercent: number;
|
||||
};
|
||||
};
|
||||
22
frontend/counter-panel/src/types/Report.ts
Normal file
22
frontend/counter-panel/src/types/Report.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface ReportItem {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
category: string;
|
||||
cashier: string;
|
||||
amount: number;
|
||||
clientName?: string;
|
||||
source: 'Web' | 'Mostrador';
|
||||
}
|
||||
|
||||
export interface GlobalReport {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
items: ReportItem[];
|
||||
totalRevenue: number;
|
||||
totalAds: number;
|
||||
totalCash: number;
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
totalTransfer: number;
|
||||
}
|
||||
2
frontend/public-web/.env
Normal file
2
frontend/public-web/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5176/api
|
||||
VITE_BASE_URL=http://localhost:5176
|
||||
187
frontend/public-web/package-lock.json
generated
187
frontend/public-web/package-lock.json
generated
@@ -8,12 +8,16 @@
|
||||
"name": "public-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@mercadopago/sdk-react": "^1.0.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"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-dropzone": "^14.3.8",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -1026,6 +1030,25 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mercadopago/sdk-js": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mercadopago/sdk-js/-/sdk-js-0.0.3.tgz",
|
||||
"integrity": "sha512-kO48DNLHdfFAp3on12nuKdqNlEmw1x3+nM6wLd04BdWOXoFcAhkNMQV3AyUIanXdO/bB/dENakdacLT29297EQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mercadopago/sdk-react": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@mercadopago/sdk-react/-/sdk-react-1.0.6.tgz",
|
||||
"integrity": "sha512-tfKvU6LceJKh1kuYh2HAWXozmfBcfvgQfroL3e2A583RfStF0C9kcr0SacdDAkz89XwZc7QCzVh4HBh/8QRuvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mercadopago/sdk-js": "^0.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.53",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||
@@ -1670,7 +1693,7 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2045,6 +2068,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
@@ -2303,7 +2335,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@@ -2729,6 +2761,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -2817,6 +2861,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",
|
||||
@@ -3035,6 +3106,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3078,7 +3158,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3437,6 +3516,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3508,6 +3599,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",
|
||||
@@ -3547,6 +3653,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3694,6 +3809,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -3731,6 +3857,49 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-helmet-async": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"shallowequal": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -3853,6 +4022,12 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3970,6 +4145,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",
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mercadopago/sdk-react": "^1.0.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"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-dropzone": "^14.3.8",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
BIN
frontend/public-web/public/sin_imagen.png
Normal file
BIN
frontend/public-web/public/sin_imagen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -1,38 +1,224 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||
import { usePublicAuthStore } from './store/publicAuthStore';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Páginas e Interfaz
|
||||
import HomePage from './pages/HomePage';
|
||||
import ListingDetailPage from './pages/ListingDetailPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PublishPage from './pages/PublishPage';
|
||||
import PublishFeedback from './pages/PublishFeedback';
|
||||
|
||||
// Componentes de Seguridad y Navegación
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import {
|
||||
User as UserIcon,
|
||||
LogIn,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
PlusCircle,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone
|
||||
} from 'lucide-react';
|
||||
|
||||
// ICONOS DE REDES SOCIALES (SVG PUROS)
|
||||
const SocialIcons = {
|
||||
Facebook: () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978 1.62 0 3.341.252 3.341.252v3.389h-1.835c-1.906 0-2.456 1.159-2.456 2.439v1.478h3.82l-.611 3.667h-3.21v7.98H9.101z" /></svg>
|
||||
),
|
||||
Instagram: () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg>
|
||||
),
|
||||
X: () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932 6.064-6.932zm-1.292 19.49h2.039L6.486 3.24H4.298L17.609 20.643z" /></svg>
|
||||
),
|
||||
Youtube: () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /></svg>
|
||||
)
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setIsUserMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<header className="bg-white border-b border-gray-100 py-4 px-6 sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<a href="/" className="font-bold text-2xl text-primary-600">SIG-CM</a>
|
||||
<nav className="hidden md:flex gap-6 text-sm font-medium text-gray-600">
|
||||
<a href="/" className="hover:text-primary-600">Inicio</a>
|
||||
<a href="#" className="hover:text-primary-600">Categorías</a>
|
||||
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
|
||||
{/* HEADER */}
|
||||
<header className="bg-white/80 backdrop-blur-md border-b border-slate-100 py-4 px-6 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<Link to="/" className="font-black text-3xl text-slate-900 tracking-tighter flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-2 h-7 rounded-full"></span>
|
||||
SIG-CM
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex gap-10 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
|
||||
<Link to="/" className="hover:text-blue-600 transition-colors">Inicio</Link>
|
||||
<a href="/#categories" className="hover:text-blue-600 transition-colors">Categorías</a>
|
||||
<a href="#" className="hover:text-blue-600 transition-colors">Soporte</a>
|
||||
</nav>
|
||||
<div>
|
||||
<a
|
||||
href="http://localhost:5177"
|
||||
className="bg-primary-600 text-white px-5 py-2 rounded-full font-medium hover:bg-primary-700 transition"
|
||||
target='_blank'
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<UserIcon size={16} />
|
||||
</div>
|
||||
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-slate-50 mb-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
|
||||
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<UserIcon size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-900 hover:text-blue-600 transition-all px-4">
|
||||
<LogIn size={18} /> Entrar
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/publicar"
|
||||
className="bg-slate-900 text-white px-8 py-3 rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-xl shadow-slate-200 hover:bg-black hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
Publicar Aviso
|
||||
</a>
|
||||
<PlusCircle size={14} className="text-blue-400" />
|
||||
Publicar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/listing/:id" element={<ListingDetailPage />} />
|
||||
</Routes>
|
||||
{/* CONTENIDO PRINCIPAL */}
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/listing/:id" element={<ListingDetailPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/publicar" element={<PublishPage />} />
|
||||
<Route path="/publicar/exito" element={<PublishFeedback />} />
|
||||
<Route path="/publicar/error" element={<PublishFeedback />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<footer className="bg-gray-900 text-gray-400 py-12 mt-auto">
|
||||
<div className="max-w-6xl mx-auto px-4 text-center">
|
||||
<p>© 2024 SIG-CM Clasificados Multicanal.</p>
|
||||
{/* FOOTER PREMIUM REFINADO */}
|
||||
<footer className="bg-[#0f172a] text-slate-400 pt-24 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-16 mb-20">
|
||||
|
||||
{/* Columna 1: Branding y Propuesta */}
|
||||
<div className="md:col-span-5 space-y-8">
|
||||
<Link to="/" className="font-black text-3xl text-white tracking-tighter flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-2 h-7 rounded-full"></span>
|
||||
SIG-CM
|
||||
</Link>
|
||||
<p className="text-sm leading-relaxed max-w-sm opacity-60 font-medium">
|
||||
La plataforma de clasificados líder en la región. Seguridad, trayectoria y efectividad garantizada por el <strong>Diario El Día</strong> de La Plata.
|
||||
</p>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-slate-500">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-blue-500"><MapPin size={18} /></div>
|
||||
Diagonal 80 No. 817, La Plata
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-slate-500">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-blue-500"><Phone size={18} /></div>
|
||||
(0221) 412-0101
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna 2: Navegación Rápida */}
|
||||
<div className="md:col-span-3 space-y-8">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-white opacity-40">Plataforma</h4>
|
||||
<ul className="space-y-5">
|
||||
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Centro de Ayuda</Link></li>
|
||||
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Preguntas Frecuentes</Link></li>
|
||||
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Términos del Servicio</Link></li>
|
||||
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Política de Privacidad</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Columna 3: Comunidad y Redes */}
|
||||
<div className="md:col-span-4 space-y-8 text-left md:text-right">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-white opacity-40">Síguenos en Redes</h4>
|
||||
<div className="flex justify-start md:justify-end gap-3">
|
||||
<SocialButton href="https://facebook.com" color="hover:bg-[#1877F2]"><SocialIcons.Facebook /></SocialButton>
|
||||
<SocialButton href="https://instagram.com" color="hover:bg-gradient-to-tr from-[#F58529] via-[#DD2A7B] to-[#8134AF]"><SocialIcons.Instagram /></SocialButton>
|
||||
<SocialButton href="https://x.com" color="hover:bg-black"><SocialIcons.X /></SocialButton>
|
||||
<SocialButton href="https://youtube.com" color="hover:bg-[#FF0000]"><SocialIcons.Youtube /></SocialButton>
|
||||
</div>
|
||||
<div className="pt-8">
|
||||
<div className="inline-flex items-center gap-3 px-5 py-3 rounded-2xl bg-white/5 border border-white/10">
|
||||
<Mail size={16} className="text-blue-500" />
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-slate-300">soporte@eldia.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Créditos Finales */}
|
||||
<div className="pt-12 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-600">
|
||||
© 2026 SIG-CM Clasificados. Todos los derechos reservados.
|
||||
</p>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-700">Diseñado para Diario El Día</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center grayscale opacity-30 hover:grayscale-0 hover:opacity-100 transition-all cursor-pointer">
|
||||
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -40,4 +226,18 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// COMPONENTE AUXILIAR PARA BOTONES DE REDES
|
||||
function SocialButton({ children, href, color }: { children: React.ReactNode, href: string, color: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-white transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-blue-500/10 ${color}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
66
frontend/public-web/src/components/LazyImage.tsx
Normal file
66
frontend/public-web/src/components/LazyImage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
// src/components/LazyImage.tsx
|
||||
|
||||
import { useLazyImage } from '../hooks/useLazyImage';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface LazyImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
fallbackSrc?: string;
|
||||
}
|
||||
|
||||
export default function LazyImage({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23f0f0f0" width="400" height="300"/%3E%3C/svg%3E',
|
||||
fallbackSrc
|
||||
}: LazyImageProps) {
|
||||
const {
|
||||
imgRef,
|
||||
imageSrc,
|
||||
isLoading,
|
||||
isError,
|
||||
handleLoad,
|
||||
handleError
|
||||
} = useLazyImage({ src, placeholder });
|
||||
|
||||
const isRealImage = imageSrc && !imageSrc.startsWith('data:');
|
||||
|
||||
return (
|
||||
<div className={clsx("relative w-full h-full overflow-hidden", className)}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-slate-200 animate-pulse z-10" />
|
||||
)}
|
||||
|
||||
{isError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-100 text-slate-400 text-[10px] p-2 text-center">
|
||||
{fallbackSrc ? (
|
||||
<img src={fallbackSrc} alt={alt} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>Imagen no disponible</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<picture className="w-full h-full">
|
||||
{/* Solo renderizamos el source si es una imagen real del servidor */}
|
||||
{isRealImage && <source srcSet={imageSrc} type="image/webp" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={clsx(
|
||||
"w-full h-full object-cover transition-opacity duration-500 block",
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,95 @@
|
||||
import type { Listing } from '../types';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { Calendar, ExternalLink, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import LazyImage from './LazyImage';
|
||||
import { motion } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function ListingCard({ listing }: { listing: Listing }) {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
// 1. Definimos la ruta de la imagen por defecto (desde la raíz de public)
|
||||
const placeholder = '/sin_imagen.png';
|
||||
|
||||
// Lógica de imagen: Si viene del backend, la usamos. Si no, placeholder local o remoto.
|
||||
const imageUrl = listing.mainImageUrl
|
||||
? `${baseUrl}${listing.mainImageUrl}`
|
||||
: 'https://placehold.co/400x300?text=Sin+Foto'; // placehold.co es más rápido y fiable que via.placeholder
|
||||
// 2. Lógica de selección de URL
|
||||
const imageUrl = listing.mainImageUrl
|
||||
? `${baseUrl}${listing.mainImageUrl}`
|
||||
: placeholder;
|
||||
|
||||
const jpgFallback = imageUrl.replace('.webp', '.jpg');
|
||||
|
||||
return (
|
||||
<Link to={`/listing/${listing.id}`} className="block h-full">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition group h-full flex flex-col">
|
||||
<div className="aspect-[4/3] bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
<Link to={`/listing/${listing.id}`} className="group relative block h-full">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white border border-slate-100 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-slate-200/60">
|
||||
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-100">
|
||||
<LazyImage
|
||||
src={imageUrl}
|
||||
fallbackSrc={jpgFallback}
|
||||
alt={listing.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
|
||||
onError={(e) => {
|
||||
// Fallback si la imagen falla al cargar
|
||||
e.currentTarget.src = 'https://placehold.co/400x300?text=Error+Carga';
|
||||
}}
|
||||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur px-2 py-1 rounded text-xs font-bold uppercase tracking-wide">
|
||||
Ver
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
|
||||
{listing.overlayStatus && (
|
||||
<div className="absolute inset-0 z-20 bg-black/40 backdrop-blur-[3px] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: listing.overlayStatus === 'Vendido' ? -5 : 5 }}
|
||||
className={clsx(
|
||||
"px-6 py-2.5 rounded-xl text-white font-black uppercase tracking-[0.2em] text-sm border-2 shadow-[0_20px_50px_rgba(0,0,0,0.3)]",
|
||||
listing.overlayStatus === 'Vendido' && "bg-rose-600 border-rose-400",
|
||||
listing.overlayStatus === 'Reservado' && "bg-amber-500 border-amber-300",
|
||||
listing.overlayStatus === 'Alquilado' && "bg-blue-600 border-blue-400"
|
||||
)}
|
||||
>
|
||||
{listing.overlayStatus}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{/* Badge ID */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="inline-flex items-center rounded-lg bg-white/95 backdrop-blur-md px-2.5 py-1 text-[10px] font-black uppercase tracking-wider text-slate-900 shadow-sm">
|
||||
#{listing.id.toString().padStart(6, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 translate-y-2 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
|
||||
<div className="rounded-full bg-blue-600 p-2.5 text-white shadow-lg">
|
||||
<ExternalLink size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col flex-grow">
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">
|
||||
{listing.currency} {listing.price.toLocaleString()}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-2.5 flex items-center gap-2">
|
||||
<span className="inline-block rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-600 uppercase tracking-tighter">
|
||||
{listing.categoryName || 'Clasificado'}
|
||||
</span>
|
||||
<div className="h-1 w-1 rounded-full bg-slate-200"></div>
|
||||
<span className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
|
||||
<Calendar size={12} className="text-slate-300" />
|
||||
{new Date(listing.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-800 mb-2 line-clamp-2 group-hover:text-primary-600 transition">
|
||||
|
||||
<h3 className="mb-4 line-clamp-2 text-lg font-extrabold text-slate-800 leading-tight group-hover:text-blue-600 transition-colors uppercase">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<div className="mt-auto pt-4 border-t border-gray-100 flex items-center text-sm text-gray-500">
|
||||
<MapPin size={16} className="mr-1" />
|
||||
<span>Ver detalles</span>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between border-t border-slate-50 pt-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black uppercase text-slate-300 tracking-widest leading-none">Importe</span>
|
||||
<div className="text-2xl font-black text-slate-900 leading-none mt-1.5 flex items-baseline">
|
||||
<span className="text-sm font-bold text-blue-500 mr-1">{listing.currency}</span>
|
||||
{listing.price.toLocaleString('es-AR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400 group-hover:text-blue-600 transition-all">
|
||||
Detalle
|
||||
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
14
frontend/public-web/src/components/ProtectedRoute.tsx
Normal file
14
frontend/public-web/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { usePublicAuthStore } from '../store/publicAuthStore';
|
||||
|
||||
export const ProtectedRoute = () => {
|
||||
const user = usePublicAuthStore((state) => state.user);
|
||||
const token = localStorage.getItem('public_token');
|
||||
|
||||
// Si no hay usuario o no hay token, al login
|
||||
if (!user || !token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
72
frontend/public-web/src/components/SEO.tsx
Normal file
72
frontend/public-web/src/components/SEO.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
interface SEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string[];
|
||||
image?: string;
|
||||
url?: string;
|
||||
type?: 'website' | 'article' | 'product';
|
||||
author?: string;
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
}
|
||||
|
||||
export default function SEO({
|
||||
title,
|
||||
description,
|
||||
keywords = [],
|
||||
image = '/og-default.jpg',
|
||||
url = window.location.href,
|
||||
type = 'website',
|
||||
author,
|
||||
publishedTime,
|
||||
modifiedTime
|
||||
}: SEOProps) {
|
||||
const siteName = 'Diario El Día - Clasificados';
|
||||
const fullTitle = `${title} | ${siteName}`;
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Primary Meta Tags */}
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="title" content={fullTitle} />
|
||||
<meta name="description" content={description} />
|
||||
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
|
||||
{author && <meta name="author" content={author} />}
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:locale" content="es_AR" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={url} />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
|
||||
{/* Article specific */}
|
||||
{type === 'article' && publishedTime && (
|
||||
<meta property="article:published_time" content={publishedTime} />
|
||||
)}
|
||||
{type === 'article' && modifiedTime && (
|
||||
<meta property="article:modified_time" content={modifiedTime} />
|
||||
)}
|
||||
{type === 'article' && author && (
|
||||
<meta property="article:author" content={author} />
|
||||
)}
|
||||
|
||||
{/* Additional SEO */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="googlebot" content="index, follow" />
|
||||
<meta name="theme-color" content="#0066CC" />
|
||||
<link rel="canonical" href={url} />
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
138
frontend/public-web/src/components/SchemaMarkup.tsx
Normal file
138
frontend/public-web/src/components/SchemaMarkup.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
interface ListingSchemaProps {
|
||||
listing: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
category: string;
|
||||
images?: string[];
|
||||
seller?: {
|
||||
name: string;
|
||||
telephone?: string;
|
||||
};
|
||||
location?: string;
|
||||
datePosted?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Schema.org markup para avisos clasificados (Product/Offer)
|
||||
export function ListingSchema({ listing }: ListingSchemaProps) {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: listing.title,
|
||||
description: listing.description,
|
||||
category: listing.category,
|
||||
image: listing.images || [],
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: listing.price,
|
||||
priceCurrency: listing.currency || 'ARS',
|
||||
availability: 'https://schema.org/InStock',
|
||||
url: `${window.location.origin}/avisos/${listing.id}`,
|
||||
seller: listing.seller ? {
|
||||
'@type': 'Organization',
|
||||
name: listing.seller.name,
|
||||
telephone: listing.seller.telephone
|
||||
} : undefined
|
||||
},
|
||||
...(listing.datePosted && { datePublished: listing.datePosted }),
|
||||
...(listing.location && {
|
||||
availableAtOrFrom: {
|
||||
'@type': 'Place',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: listing.location
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
|
||||
// Schema.org para la organización (footer del sitio)
|
||||
export function OrganizationSchema() {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Diario El Día',
|
||||
url: window.location.origin,
|
||||
logo: `${window.location.origin}/logo.png`,
|
||||
sameAs: [
|
||||
'https://www.facebook.com/diarioeldia',
|
||||
'https://twitter.com/diarioeldia',
|
||||
'https://www.instagram.com/diarioeldia'
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer service',
|
||||
telephone: '+54-11-1234-5678',
|
||||
availableLanguage: 'Spanish'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
|
||||
// Schema.org para breadcrumbs de navegación
|
||||
export function BreadcrumbSchema({ items }: { items: Array<{ name: string; url: string }> }) {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url
|
||||
}))
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
|
||||
// Schema.org para formulario de búsqueda
|
||||
export function WebsiteSearchSchema() {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url: window.location.origin,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${window.location.origin}/buscar?q={search_term_string}`
|
||||
},
|
||||
'query-input': 'required name=search_term_string'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
@@ -16,19 +16,19 @@ export default function SearchBar({ onSearch }: SearchBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="w-full max-w-2xl relative">
|
||||
<form onSubmit={handleSearch} className="w-full relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="¿Qué estás buscando? (Ej: Departamento, Fiat 600...)"
|
||||
className="w-full py-4 pl-6 pr-14 rounded-full border border-gray-200 shadow-lg text-gray-800 text-lg focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all placeholder:text-gray-400"
|
||||
className="w-full py-4 pl-8 pr-16 rounded-[1.8rem] bg-slate-900/40 border-none text-white text-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all placeholder:text-slate-500 font-medium"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-2 p-2 bg-primary-600 text-white rounded-full hover:bg-primary-700 transition flex items-center justify-center w-10 h-10"
|
||||
className="absolute right-2 w-12 h-12 bg-blue-600 text-white rounded-full hover:bg-blue-500 transition-all flex items-center justify-center shadow-lg shadow-blue-600/20 active:scale-90"
|
||||
>
|
||||
<Search size={20} />
|
||||
<Search size={20} strokeWidth={3} />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
18
frontend/public-web/src/hooks/useDebounce.ts
Normal file
18
frontend/public-web/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Hook para debounce (evita llamadas excesivas al escribir)
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
66
frontend/public-web/src/hooks/useLazyImage.ts
Normal file
66
frontend/public-web/src/hooks/useLazyImage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseLazyImageProps {
|
||||
src: string;
|
||||
placeholder?: string;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
// Hook personalizado para lazy loading de imágenes
|
||||
export function useLazyImage({ src, placeholder = '', threshold = 0.25 }: UseLazyImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState(placeholder);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver;
|
||||
let didCancel = false;
|
||||
|
||||
const currentImg = imgRef.current;
|
||||
if (currentImg && imageSrc === placeholder) {
|
||||
// Crear observer que detecta cuando la imagen entra en viewport
|
||||
observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!didCancel && entry.isIntersecting) {
|
||||
setIsLoading(true);
|
||||
setImageSrc(src);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '50px' // Empezar a cargar 50px antes de que sea visible
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(currentImg);
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
if (observer && currentImg) {
|
||||
observer.unobserve(currentImg);
|
||||
}
|
||||
};
|
||||
}, [src, imageSrc, placeholder, threshold]);
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
return {
|
||||
imgRef,
|
||||
imageSrc,
|
||||
isLoading,
|
||||
isError,
|
||||
handleLoad,
|
||||
handleError
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
</HelmetProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
@@ -1,21 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import SearchBar from '../components/SearchBar';
|
||||
import ListingCard from '../components/ListingCard';
|
||||
import { publicService } from '../services/publicService';
|
||||
import type { Listing, Category } from '../types';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import type { Listing } from '../types';
|
||||
import { Filter, X, Grid, List as ListIcon, TrendingUp, Search, Zap } from 'lucide-react';
|
||||
import { processCategoriesForSelect, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import SEO from '../components/SEO';
|
||||
import { WebsiteSearchSchema, OrganizationSchema } from '../components/SchemaMarkup';
|
||||
|
||||
export default function HomePage() {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
|
||||
// Usamos FlatCategory para el renderizado
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [rawCategories, setRawCategories] = useState<Category[]>([]); // Guardamos raw para los botones del home
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estado de Búsqueda
|
||||
// Search State
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
|
||||
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string>>({});
|
||||
@@ -32,36 +31,38 @@ export default function HomePage() {
|
||||
publicService.getCategories()
|
||||
]);
|
||||
setListings(latestListings);
|
||||
setRawCategories(cats);
|
||||
|
||||
// Procesamos el árbol para el select
|
||||
const processed = processCategoriesForSelect(cats);
|
||||
setFlatCategories(processed);
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
setFlatCategories(processCategoriesForSelect(cats));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async () => {
|
||||
const performSearch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await import('../services/api').then(m => m.default.post('/listings/search', {
|
||||
const { default: api } = await import('../services/api');
|
||||
const response = await api.post('/listings/search', {
|
||||
query: searchText,
|
||||
categoryId: selectedCatId,
|
||||
filters: dynamicFilters
|
||||
}));
|
||||
attributes: dynamicFilters
|
||||
});
|
||||
setListings(response.data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchText, selectedCatId, dynamicFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText || selectedCatId || Object.keys(dynamicFilters).length > 0) {
|
||||
if (selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) {
|
||||
performSearch();
|
||||
}
|
||||
}, [selectedCatId, dynamicFilters]);
|
||||
}, [selectedCatId, dynamicFilters, searchText, performSearch]);
|
||||
|
||||
const handleSearchText = (q: string) => {
|
||||
const handleSearchSubmit = (q: string) => {
|
||||
setSearchText(q);
|
||||
performSearch();
|
||||
};
|
||||
@@ -70,98 +71,289 @@ export default function HomePage() {
|
||||
setDynamicFilters({});
|
||||
setSelectedCatId(null);
|
||||
setSearchText('');
|
||||
publicService.getLatestListings().then(setListings); // Reset list
|
||||
}
|
||||
|
||||
// Para los botones del Home, solo mostramos los Raíz
|
||||
const rootCategories = rawCategories.filter(c => !c.parentId);
|
||||
loadInitialData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
{/* Hero */}
|
||||
<div className="bg-primary-900 text-white py-16 px-4 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-900 to-gray-900 opacity-90"></div>
|
||||
<div className="max-w-4xl mx-auto text-center relative z-10">
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
|
||||
<div className="flex justify-center">
|
||||
<SearchBar onSearch={handleSearchText} />
|
||||
</div>
|
||||
<div className="min-h-screen bg-[#f8fafc] font-sans pb-24">
|
||||
<SEO
|
||||
title="Los Mejores Clasificados"
|
||||
description="Encuentra autos, inmuebles y servicios verificados. Publica tu aviso con la confianza del Diario El Día."
|
||||
/>
|
||||
<WebsiteSearchSchema />
|
||||
<OrganizationSchema />
|
||||
|
||||
{/* --- HERO SECTION REFINADO --- */}
|
||||
<div className="relative bg-[#020617] pt-24 pb-36 px-4 overflow-hidden">
|
||||
|
||||
{/* Capas de fondo: Orbes de luz y textura */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[60%] bg-blue-600/10 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-5%] w-[30%] h-[50%] bg-indigo-500/10 rounded-full blur-[100px]"></div>
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-[0.03]"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto text-center relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Badge superior estilizado */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-500/5 border border-blue-500/20 mb-8 shadow-2xl">
|
||||
<Zap size={14} className="text-blue-400 fill-blue-400/20" />
|
||||
<span className="text-[9px] font-black text-blue-300 uppercase tracking-[0.3em]">
|
||||
Marketplace Oficial Diario El Día
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-black text-white mb-6 tracking-tighter leading-[0.95] uppercase">
|
||||
Tu próximo <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-blue-500 to-indigo-400 drop-shadow-[0_0_25px_rgba(59,130,246,0.3)]">
|
||||
gran hallazgo
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-slate-400 text-base md:text-lg max-w-xl mx-auto mb-12 font-medium leading-relaxed opacity-80">
|
||||
Explora miles de clasificados verificados con la confianza de siempre, ahora en una experiencia digital Premium.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Buscador con efecto Glassmorphism */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="max-w-2xl mx-auto"
|
||||
>
|
||||
<div className="bg-white/5 backdrop-blur-md p-1.5 rounded-[2rem] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.3)]">
|
||||
<SearchBar onSearch={handleSearchSubmit} />
|
||||
</div>
|
||||
|
||||
{/* Sugerencias rápidas */}
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-4">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tendencias:</span>
|
||||
{['Departamentos', 'Camionetas', 'Motos', 'Servicios'].map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className="text-[10px] font-black text-slate-400 hover:text-blue-400 uppercase tracking-tighter transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Degradado hacia la sección blanca de abajo */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-24 bg-gradient-to-t from-[#f8fafc] to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 mt-8 flex flex-col lg:flex-row gap-8">
|
||||
{/* Main Content Layout */}
|
||||
<div className="max-w-7xl mx-auto px-4 -mt-24 relative z-20">
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
|
||||
{/* SIDEBAR DE FILTROS */}
|
||||
<div className="w-full lg:w-64 flex-shrink-0 space-y-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Filter size={18} /> Filtros
|
||||
</h3>
|
||||
{(selectedCatId || Object.keys(dynamicFilters).length > 0) && (
|
||||
<button onClick={clearFilters} className="text-xs text-red-500 hover:underline flex items-center">
|
||||
<X size={12} /> Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtro Categoría (MEJORADO) */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Categoría</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded p-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
value={selectedCatId || ''}
|
||||
onChange={(e) => setSelectedCatId(Number(e.target.value) || null)}
|
||||
>
|
||||
<option value="">Todas las categorías</option>
|
||||
{flatCategories.map(cat => (
|
||||
<option
|
||||
key={cat.id}
|
||||
value={cat.id}
|
||||
className={cat.level === 0 ? "font-bold text-gray-900" : "text-gray-600"}
|
||||
{/* SIDEBAR: Advanced Filters */}
|
||||
<aside className="w-full lg:w-80 flex-shrink-0">
|
||||
<div className="bg-white rounded-[3rem] shadow-2xl shadow-slate-200/40 border border-slate-100 p-10 mt-10 lg:mt-0 sticky top-28">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h3 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
|
||||
<Filter size={18} />
|
||||
</div>
|
||||
Filtros
|
||||
</h3>
|
||||
{(selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-[9px] font-black text-red-500 hover:bg-red-50 hover:px-3 py-2 rounded-xl transition-all flex items-center gap-1 uppercase tracking-tighter"
|
||||
>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<X size={12} /> Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtros Dinámicos */}
|
||||
{selectedCatId && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase">Atributos</p>
|
||||
<div className="space-y-10">
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar por Kilometraje..."
|
||||
className="w-full border p-2 rounded text-sm"
|
||||
onChange={(e) => setDynamicFilters({ ...dynamicFilters, 'Kilometraje': e.target.value })}
|
||||
/>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 ml-1">Rubro Principal</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4.5 text-sm font-black text-slate-800 focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 outline-none transition-all appearance-none cursor-pointer"
|
||||
value={selectedCatId || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedCatId(Number(e.target.value) || null);
|
||||
setDynamicFilters({});
|
||||
}}
|
||||
>
|
||||
<option value="">Todos los rubros</option>
|
||||
{flatCategories.map(cat => (
|
||||
<option
|
||||
key={cat.id}
|
||||
value={cat.id}
|
||||
className={cat.level === 0 ? "font-black" : "font-semibold"}
|
||||
>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-300">
|
||||
<Filter size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Attributes */}
|
||||
<AnimatePresence>
|
||||
{selectedCatId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="space-y-6 pt-10 border-t border-slate-50"
|
||||
>
|
||||
<p className="flex items-center gap-3 text-[10px] font-black text-blue-600 uppercase tracking-widest">
|
||||
<TrendingUp size={16} /> Búsqueda avanzada
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Kilometraje, Ambientes..."
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-bold text-slate-700 outline-none focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 transition-all placeholder:text-slate-300"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
if (val) {
|
||||
setDynamicFilters({ ...dynamicFilters, [Date.now()]: val });
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300">
|
||||
<Search size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(dynamicFilters).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(dynamicFilters).map(([k, v]) => (
|
||||
<motion.span
|
||||
key={k}
|
||||
layout
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-blue-600 text-white px-3 py-1.5 rounded-xl text-[10px] font-black flex items-center gap-2 shadow-lg shadow-blue-200 uppercase tracking-tighter"
|
||||
>
|
||||
{v}
|
||||
<X
|
||||
size={12}
|
||||
className="cursor-pointer hover:bg-white/20 rounded"
|
||||
onClick={() => {
|
||||
const next = { ...dynamicFilters }; delete next[k]; setDynamicFilters(next);
|
||||
}}
|
||||
/>
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* MAIN LIST: Content Grid */}
|
||||
<main className="flex-1 lg:pt-10">
|
||||
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<motion.h2
|
||||
key={selectedCatId || 'root'}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none"
|
||||
>
|
||||
{loading ? 'Sincronizando...' : selectedCatId
|
||||
? flatCategories.find(c => c.id === selectedCatId)?.name
|
||||
: 'Últimos Clasificados'}
|
||||
</motion.h2>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className="flex items-center gap-1.5 px-3 py-1 bg-white rounded-full border border-slate-100 text-[10px] font-black text-slate-400 uppercase tracking-widest shadow-sm">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
|
||||
{listings.length} resultados
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LISTADO */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{loading ? 'Cargando...' : selectedCatId
|
||||
? `${listings.length} Resultados en ${flatCategories.find(c => c.id === selectedCatId)?.name}`
|
||||
: 'Resultados Recientes'}
|
||||
</h2>
|
||||
|
||||
{!loading && listings.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-20 bg-white rounded-lg border border-dashed border-gray-300">
|
||||
No se encontraron avisos con esos criterios.
|
||||
<div className="flex items-center bg-white p-1.5 rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100">
|
||||
<button className="p-2.5 bg-slate-900 text-white rounded-xl shadow-lg">
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button className="p-2.5 text-slate-300 hover:text-slate-500 rounded-xl">
|
||||
<ListIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{listings.map(listing => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
{/* Grid Container */}
|
||||
<div className="relative min-h-[600px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<div key={i} className="bg-white rounded-[3rem] h-[420px] animate-pulse border border-slate-50 shadow-sm"></div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : listings.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center justify-center py-40 bg-white rounded-[4rem] border border-slate-100 shadow-2xl shadow-slate-200/50 group"
|
||||
>
|
||||
<div className="bg-slate-50 w-32 h-32 rounded-full flex items-center justify-center mb-8 border border-white shadow-inner group-hover:scale-110 transition-transform duration-500">
|
||||
<Search size={48} className="text-slate-200 group-hover:text-blue-200 transition-colors" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter">Sin resultados</h3>
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-[10px] mt-2 mb-10 max-w-xs text-center opacity-70">
|
||||
No hay avisos que coincidan con los criterios aplicados actualmente.
|
||||
</p>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="bg-slate-900 hover:bg-black text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-[0.2em] shadow-2xl shadow-slate-200 active:scale-95 transition-all"
|
||||
>
|
||||
Restablecer vista
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
|
||||
>
|
||||
{listings.map((listing, idx) => (
|
||||
<motion.div
|
||||
key={listing.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05, duration: 0.4 }}
|
||||
>
|
||||
<ListingCard listing={listing} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { publicService } from '../services/publicService';
|
||||
import type { ListingDetail } from '../types';
|
||||
import { Calendar, Tag, ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
Tag, ChevronLeft, Share2, MessageCircle,
|
||||
Info, Calendar, Eye, ShieldCheck, Heart
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import LazyImage from '../components/LazyImage';
|
||||
import SEO from '../components/SEO';
|
||||
import { ListingSchema } from '../components/SchemaMarkup';
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { id } = useParams();
|
||||
@@ -15,102 +22,189 @@ export default function ListingDetailPage() {
|
||||
publicService.getListingDetail(parseInt(id))
|
||||
.then(data => {
|
||||
setDetail(data);
|
||||
if (data.images.length > 0) {
|
||||
setActiveImage(data.images[0].url);
|
||||
}
|
||||
if (data.images.length > 0) setActiveImage(data.images[0].url);
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center">Cargando...</div>;
|
||||
if (!detail) return <div className="min-h-screen flex items-center justify-center">Aviso no encontrado.</div>;
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!detail) return <div>No encontrado</div>;
|
||||
|
||||
const { listing, attributes, images } = detail;
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL; // Ajustar puerto según backend launchSettings
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<a href="/" className="inline-flex items-center text-gray-500 hover:text-primary-600 mb-6">
|
||||
<ChevronLeft size={20} /> Volver al listado
|
||||
</a>
|
||||
<div className="min-h-screen bg-[#f8fafc] font-sans pb-24">
|
||||
{/* --- SEO Y METADATOS (Invisibles) --- */}
|
||||
<SEO title={listing.title} description={listing.description} image={activeImage} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Galería de Fotos */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="aspect-video bg-gray-100 rounded-xl overflow-hidden border border-gray-200">
|
||||
{activeImage ? (
|
||||
<img src={`${baseUrl}${activeImage}`} alt={listing.title} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">Sin imágenes</div>
|
||||
)}
|
||||
{/* RE-INTEGRADO: Vital para que Google muestre el precio en las búsquedas */}
|
||||
<ListingSchema listing={{
|
||||
id: listing.id,
|
||||
title: listing.title,
|
||||
description: listing.description || '',
|
||||
price: listing.price,
|
||||
category: listing.categoryName || 'Clasificados',
|
||||
images: images.map(i => `${baseUrl}${i.url}`)
|
||||
}} />
|
||||
|
||||
{/* --- NAVEGACIÓN SUPERIOR --- */}
|
||||
<div className="bg-white/80 backdrop-blur-md border-b border-slate-100 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<ChevronLeft size={16} /> Volver al listado
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 text-slate-400 hover:text-rose-500 transition-colors"><Heart size={20} /></button>
|
||||
<button className="p-2 text-slate-400 hover:text-blue-500 transition-colors"><Share2 size={20} /></button>
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{images.map(img => (
|
||||
<button
|
||||
key={img.id}
|
||||
onClick={() => setActiveImage(img.url)}
|
||||
className={`w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden border-2 transition ${activeImage === img.url ? 'border-primary-600' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<img src={`${baseUrl}${img.url}`} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Información Principal */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="bg-primary-50 text-primary-700 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Clasificado #{listing.id}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
{new Date(listing.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 pt-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{listing.title}</h1>
|
||||
<div className="text-4xl font-bold text-primary-600 mb-6">
|
||||
{listing.currency} {listing.price.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<button className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition mb-4">
|
||||
Contactar Anunciante
|
||||
</button>
|
||||
<button className="w-full bg-white border border-gray-300 text-gray-700 font-bold py-3 rounded-lg hover:bg-gray-50 transition">
|
||||
Compartir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Atributos Dinámicos */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Tag size={20} className="text-primary-600" />
|
||||
Características
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-y-4 gap-x-2">
|
||||
{attributes.map(attr => (
|
||||
<div key={attr.id} className="border-b border-gray-50 pb-2">
|
||||
<span className="block text-xs text-gray-500 uppercase tracking-wide">{attr.attributeName}</span>
|
||||
<span className="font-medium text-gray-800">{attr.value}</span>
|
||||
{/* --- COLUMNA IZQUIERDA: VISUALES --- */}
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<div className="bg-white rounded-[2.5rem] p-3 shadow-2xl shadow-slate-200/50 border border-slate-100 relative group">
|
||||
<div className="aspect-[16/10] rounded-[2rem] overflow-hidden bg-slate-50 relative">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={activeImage} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="w-full h-full">
|
||||
<LazyImage src={`${baseUrl}${activeImage}`} alt={listing.title} className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className="absolute top-6 left-6 bg-white/90 backdrop-blur-md px-4 py-2 rounded-2xl flex items-center gap-2 shadow-xl border border-white/20">
|
||||
<ShieldCheck size={16} className="text-blue-600" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-900">Verificado</span>
|
||||
</div>
|
||||
))}
|
||||
{attributes.length === 0 && <p className="text-gray-400 text-sm">Sin características adicionales.</p>}
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-3 mt-4 px-2 overflow-x-auto pb-2">
|
||||
{images.map(img => (
|
||||
<button key={img.id} onClick={() => setActiveImage(img.url)} className={`relative w-16 h-16 rounded-xl overflow-hidden border-2 transition-all flex-shrink-0 ${activeImage === img.url ? 'border-blue-600 shadow-md scale-105' : 'border-transparent opacity-60'}`}>
|
||||
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] p-10 shadow-xl shadow-slate-200/40 border border-slate-100">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-1 h-6 bg-blue-600 rounded-full"></div>
|
||||
<h3 className="text-lg font-black text-slate-900 uppercase tracking-tighter">Reseña del Anunciante</h3>
|
||||
</div>
|
||||
<p className="text-slate-500 text-lg leading-relaxed font-medium italic">
|
||||
"{listing.description || 'Sin descripción detallada.'}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-semibold mb-2">Descripción</h3>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
{listing.description || "Sin descripción proporcionada."}
|
||||
</p>
|
||||
</div>
|
||||
{/* --- COLUMNA DERECHA: INFO Y ACCIÓN (TODO DENTRO DE UNA CAJA STICKY) --- */}
|
||||
<aside className="lg:col-span-4">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
|
||||
{/* CARD PRINCIPAL DE COMPRA */}
|
||||
<div className="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/60 border border-slate-100 overflow-hidden">
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-[9px] font-black text-slate-300 uppercase tracking-widest">ID_#{listing.id.toString().padStart(6, '0')}</span>
|
||||
<span className="flex items-center gap-1.5 text-emerald-500 font-black text-[9px] uppercase bg-emerald-50 px-2 py-1 rounded-lg">
|
||||
<div className="w-1 h-1 bg-emerald-500 rounded-full animate-pulse"></div> en stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
|
||||
{listing.title}
|
||||
</h1>
|
||||
|
||||
{/* Bloque de Precio Ajustado */}
|
||||
<div className="mb-8 p-5 bg-slate-50 rounded-3xl border border-slate-100 relative group overflow-hidden">
|
||||
<Tag
|
||||
size={30}
|
||||
className="absolute right-1 top-1 text-slate-200 rotate-12 group-hover:text-blue-100 transition-colors duration-500"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1 relative z-10">
|
||||
<Tag size={12} className="text-blue-500" /> {/* Versión mini funcional */}
|
||||
<span className="text-[9px] font-black uppercase text-slate-400 tracking-widest block">
|
||||
Precio Final de Venta
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap relative z-10">
|
||||
<span className="text-lg font-black text-blue-600">{listing.currency}</span>
|
||||
<span className="text-4xl font-black text-slate-950 tracking-tighter">
|
||||
{listing.price.toLocaleString('es-AR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
|
||||
<MessageCircle size={18} />
|
||||
Contactar Vendedor
|
||||
</button>
|
||||
<button className="w-full py-4 bg-slate-900 hover:bg-black text-white rounded-2xl font-black uppercase text-[11px] tracking-widest transition-all active:scale-95 shadow-xl shadow-slate-200">
|
||||
Realizar Oferta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats integrados */}
|
||||
<div className="flex justify-between pt-6 mt-6 border-t border-slate-50">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black text-slate-400 uppercase mb-1">Publicado</span>
|
||||
<span className="text-[11px] font-bold text-slate-600 flex items-center gap-1">
|
||||
<Calendar size={12} className="text-blue-500" /> {new Date(listing.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[8px] font-black text-slate-400 uppercase mb-1">Interés</span>
|
||||
<span className="text-[11px] font-bold text-slate-600 flex items-center gap-1">
|
||||
<Eye size={12} className="text-orange-500" /> {listing.viewCount} visitas
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FICHA TÉCNICA INTEGRADA (Para que el sticky no la tape) */}
|
||||
<div className="bg-slate-50/50 border-t border-slate-100 p-8">
|
||||
<h4 className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
|
||||
Especificaciones Técnicas
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{attributes.map(attr => (
|
||||
<div key={attr.id} className="flex justify-between items-center py-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">{attr.attributeName}</span>
|
||||
<span className="font-black text-slate-800 text-xs tracking-tight">{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONSEJO DE SEGURIDAD FUERA DE LA CARD PRINCIPAL PERO DENTRO DEL STICKY */}
|
||||
<div className="p-6 rounded-[2rem] bg-indigo-900 text-white relative overflow-hidden shadow-xl">
|
||||
<div className="flex gap-4 items-start relative z-10">
|
||||
<Info size={20} className="text-indigo-400 flex-shrink-0" />
|
||||
<div>
|
||||
<h5 className="font-black uppercase text-[9px] tracking-widest text-indigo-200 mb-1">Consejo de Seguridad</h5>
|
||||
<p className="text-[10px] text-slate-300 leading-snug">
|
||||
Verifica la identidad del vendedor y no transfieras dinero sin ver el producto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
224
frontend/public-web/src/pages/LoginPage.tsx
Normal file
224
frontend/public-web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { publicAuthService } from '../services/authService';
|
||||
import { usePublicAuthStore } from '../store/publicAuthStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Mail, Lock, User, ArrowRight, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const setUser = usePublicAuthStore(state => state.setUser);
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showMfa, setShowMfa] = useState(false);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
const res = await publicAuthService.login(formData.username, formData.password);
|
||||
if (res.success) {
|
||||
if (res.requiresMfa) {
|
||||
setShowMfa(true);
|
||||
} else {
|
||||
setUser({ username: formData.username });
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
setError(res.errorMessage || 'Error al iniciar sesión');
|
||||
}
|
||||
} else {
|
||||
const res = await publicAuthService.register(formData.username, formData.email, formData.password);
|
||||
if (res.success) {
|
||||
setIsLogin(true);
|
||||
setError('Registro exitoso. Por favor inicie sesión.');
|
||||
} else {
|
||||
setError(res.errorMessage || 'Error al registrarse');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Error de conexión');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showMfa) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 font-display">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white w-full max-w-md p-10 rounded-[2.5rem] shadow-2xl border border-slate-100"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<div className="bg-blue-600 w-16 h-16 rounded-3xl flex items-center justify-center text-white mx-auto shadow-xl shadow-blue-500/20 mb-6">
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-slate-900 tracking-tight">Verificación MFA</h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-[10px] mt-2">Ingrese el código de su aplicación de autenticación</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
className="w-full text-center text-4xl font-black tracking-[0.5em] py-6 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
className="w-full bg-slate-900 text-white font-black uppercase tracking-widest py-5 rounded-3xl shadow-xl hover:bg-black transition-all"
|
||||
onClick={() => navigate('/')} // En un flujo real llamaríamos a verifyMfa
|
||||
>
|
||||
Verificar Identidad
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center p-6 font-display">
|
||||
<div className="w-full max-w-5xl bg-white rounded-[3rem] shadow-2xl overflow-hidden flex flex-col md:flex-row border border-slate-100">
|
||||
|
||||
{/* Lado Izquierdo: Visual & Branding */}
|
||||
<div className="md:w-1/2 bg-slate-900 p-12 flex flex-col justify-between text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/20 rounded-full -mr-32 -mt-32 blur-3xl"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="bg-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center mb-8 shadow-lg shadow-blue-500/20">
|
||||
<ShieldCheck size={24} />
|
||||
</div>
|
||||
<h2 className="text-5xl font-black tracking-tighter leading-none mb-6">
|
||||
Vende y compra con total confianza.
|
||||
</h2>
|
||||
<p className="text-slate-400 font-medium text-lg leading-relaxed">
|
||||
Únete a la comunidad de clasificados más grande de la región. Seguridad verificada y contacto directo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 relative z-10">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-black tracking-tight">25k+</span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Avisos activos</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-black tracking-tight">10k+</span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Usuarios verificados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Derecho: Formulario */}
|
||||
<div className="md:w-1/2 p-12 lg:p-16">
|
||||
<div className="flex justify-between items-center mb-12">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-slate-900 tracking-tight">
|
||||
{isLogin ? '¡Hola de nuevo!' : 'Crea tu cuenta'}
|
||||
</h1>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">
|
||||
{isLogin ? 'Accede a tu panel personal' : 'Registrarse es gratis y rápido'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Nombre de usuario</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
|
||||
placeholder="ej: juanperez"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Correo Electrónico</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Contraseña</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="p-4 bg-rose-50 rounded-2xl text-rose-600 text-xs font-bold border border-rose-100">
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white font-black uppercase tracking-widest py-5 rounded-3xl shadow-xl shadow-blue-500/30 hover:bg-blue-700 hover:-translate-y-1 active:translate-y-0 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
{loading ? 'Cargando...' : isLogin ? 'Entrar' : 'Registrarme'}
|
||||
<ArrowRight size={20} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divisor */}
|
||||
<div className="my-10 flex items-center gap-4 text-slate-300">
|
||||
<div className="flex-1 h-px bg-slate-100"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest px-2">O continuar con</span>
|
||||
<div className="flex-1 h-px bg-slate-100"></div>
|
||||
</div>
|
||||
|
||||
{/* Google Login Placeholder Button */}
|
||||
<button className="w-full bg-white text-slate-900 border-2 border-slate-100 font-bold py-5 rounded-3xl flex items-center justify-center gap-4 hover:bg-slate-50 transition-all shadow-sm">
|
||||
<img src="https://www.svgrepo.com/show/475656/google-color.svg" className="w-6 h-6" alt="Google" />
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<button
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
{isLogin ? '¿No tienes cuenta? Regístrate aquí' : '¿Ya tienes cuenta? Inicia sesión'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
478
frontend/public-web/src/pages/ProfilePage.tsx
Normal file
478
frontend/public-web/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { publicAuthService } from '../services/authService';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { Listing } from '../types';
|
||||
import {
|
||||
User, Package, Settings, ChevronRight,
|
||||
Clock, Eye, ShieldCheck, QrCode, Lock,
|
||||
Bell, RefreshCcw
|
||||
} from 'lucide-react';
|
||||
import { usePublicAuthStore } from '../store/publicAuthStore';
|
||||
import api from '../services/api';
|
||||
import LazyImage from '../components/LazyImage';
|
||||
import { useWizardStore } from '../store/wizardStore';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface MfaData {
|
||||
qrCodeUri: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
type TabType = 'listings' | 'security' | 'settings';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('listings');
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaData | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
const setRepublishData = useWizardStore(state => state.setRepublishData);
|
||||
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadMyData();
|
||||
}, [navigate, user]);
|
||||
|
||||
const handleConfirmRepublish = async () => {
|
||||
if (!republishTarget) return;
|
||||
try {
|
||||
// Buscamos el detalle técnico (Modelo, KM, Puertas, etc.)
|
||||
const res = await api.get(`/listings/${republishTarget.id}`);
|
||||
|
||||
// Cargamos el Store con toda la data
|
||||
setRepublishData(res.data);
|
||||
|
||||
// Navegamos al Wizard
|
||||
navigate('/publicar');
|
||||
} catch (error) {
|
||||
alert("Error al recuperar datos técnicos del aviso");
|
||||
} finally {
|
||||
setRepublishTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMyData = async () => {
|
||||
try {
|
||||
const response = await api.get('/listings/my');
|
||||
setListings(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error al cargar datos del perfil", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupMfa = async () => {
|
||||
try {
|
||||
const data = await publicAuthService.setupMfa();
|
||||
setMfaSetupData(data);
|
||||
} catch (error) {
|
||||
console.error("Error al configurar MFA", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyAndEnableMfa = async () => {
|
||||
if (mfaCode.length !== 6) return;
|
||||
try {
|
||||
const res = await publicAuthService.verifyMfa(mfaCode);
|
||||
if (res.success) {
|
||||
alert("¡MFA activado con éxito!");
|
||||
setMfaSetupData(null);
|
||||
setMfaCode('');
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error al verificar código.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateOverlay = async (id: number, status: string | null) => {
|
||||
try {
|
||||
await api.patch(`/listings/${id}/overlay`, JSON.stringify(status), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
loadMyData();
|
||||
} catch (error) {
|
||||
alert("Error al actualizar estado");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] font-sans">
|
||||
|
||||
{/* 1. HEADER REFINADO (Más bajo y elegante) */}
|
||||
<div className="bg-[#0f172a] relative overflow-hidden pt-12 pb-20">
|
||||
{/* Luces de fondo más tenues */}
|
||||
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-blue-500/5 rounded-full blur-[100px]"></div>
|
||||
<div className="absolute bottom-0 left-1/4 w-[300px] h-[300px] bg-indigo-500/5 rounded-full blur-[80px]"></div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 relative z-10">
|
||||
<div className="flex flex-col md:flex-row items-end gap-8">
|
||||
|
||||
{/* Avatar más integrado */}
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 bg-white rounded-[2rem] flex items-center justify-center shadow-2xl border border-white/10 relative z-10">
|
||||
<User size={40} className="text-slate-900" />
|
||||
</div>
|
||||
<div className="absolute z-20 -bottom-2 -right-2 bg-emerald-500 w-8 h-8 rounded-full border-4 border-[#0f172a] flex items-center justify-center text-white shadow-lg">
|
||||
<ShieldCheck size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center md:text-left flex-1 pb-2">
|
||||
<div className="flex items-center justify-center md:justify-start gap-4 mb-3">
|
||||
<h1 className="text-3xl font-black text-white tracking-tighter uppercase leading-none">
|
||||
{user?.username}
|
||||
</h1>
|
||||
<span className="bg-blue-500/10 text-blue-400 px-3 py-1.5 rounded-xl text-[9px] font-black uppercase tracking-[0.2em] border border-blue-500/20">
|
||||
Verificado
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center md:justify-start gap-6 text-slate-400">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest">
|
||||
<Package size={14} className="text-blue-500" />
|
||||
{listings.filter(l => l.status === 'Published').length} ACTIVAS
|
||||
</div>
|
||||
<div className="w-1 h-1 bg-slate-800 rounded-full"></div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest">
|
||||
<Clock size={14} className="text-slate-600" />
|
||||
Miembro desde 2025
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pb-2">
|
||||
<button className="bg-white/5 hover:bg-white/10 text-white p-3 rounded-2xl border border-white/10 transition-all">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/'); }}
|
||||
className="bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white px-6 py-3 rounded-2xl border border-rose-500/20 transition-all text-[10px] font-black uppercase tracking-widest"
|
||||
>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. CONTENIDO (Sidebar + Main) */}
|
||||
<div className="max-w-6xl mx-auto px-6 -mt-8 relative z-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
|
||||
{/* SIDEBAR MÁS ESTILIZADO */}
|
||||
<aside className="lg:col-span-3">
|
||||
<div className="bg-white/80 backdrop-blur-xl rounded-[2.5rem] p-3 shadow-2xl shadow-slate-200/50 border border-white sticky top-24">
|
||||
<nav className="space-y-1">
|
||||
<SidebarItem
|
||||
icon={<Package size={18} />}
|
||||
label="Publicaciones"
|
||||
active={activeTab === 'listings'}
|
||||
onClick={() => setActiveTab('listings')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Lock size={18} />}
|
||||
label="Seguridad"
|
||||
active={activeTab === 'security'}
|
||||
onClick={() => setActiveTab('security')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Settings size={18} />}
|
||||
label="Ajustes"
|
||||
active={activeTab === 'settings'}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* CONTENIDO DINÁMICO */}
|
||||
<main className="lg:col-span-9">
|
||||
<div className="bg-white rounded-[3rem] p-10 shadow-2xl shadow-slate-200/60 border border-slate-100 min-h-[550px]">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'listings' && (
|
||||
<motion.div key="listings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Historial de anuncios</h2>
|
||||
{listings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-30">
|
||||
<Package size={64} className="mb-4" />
|
||||
<p className="font-black uppercase tracking-widest text-xs">Sin actividad reciente</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{listings.map(item => (
|
||||
<div key={item.id} className="flex flex-col p-6 bg-slate-50 hover:bg-white hover:shadow-xl rounded-[2.5rem] transition-all border border-transparent hover:border-slate-100 group">
|
||||
<div className="flex gap-6 items-start">
|
||||
{/* Contenedor de imagen con tamaño fijo y aspecto cuadrado */}
|
||||
<div className="w-24 h-24 bg-white rounded-3xl overflow-hidden flex-shrink-0 relative border border-slate-100 shadow-sm">
|
||||
{item.mainImageUrl ? (
|
||||
<LazyImage
|
||||
src={`${baseUrl}${item.mainImageUrl}`}
|
||||
alt={item.title}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-200 bg-slate-50">
|
||||
<Package size={32} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-[96px] justify-between">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors uppercase text-sm">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1 uppercase">
|
||||
<Clock size={12} /> {new Date(item.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1 uppercase">
|
||||
<Eye size={12} /> {item.viewCount} vistas
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p>
|
||||
|
||||
{/* BADGE DINÁMICO DE ESTADO */}
|
||||
<span className={clsx(
|
||||
"text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1",
|
||||
// Estilos para Publicado
|
||||
item.status === 'Published' && "text-emerald-500 bg-emerald-50 border-emerald-100",
|
||||
// Estilos para Pendiente de Moderación (Naranja)
|
||||
item.status === 'Pending' && "text-amber-500 bg-amber-50 border-amber-100",
|
||||
// Estilos para Borrador o Error (Gris)
|
||||
(item.status === 'Draft' || !item.status) && "text-slate-400 bg-slate-50 border-slate-200"
|
||||
)}>
|
||||
{item.status === 'Published' ? 'Publicado' :
|
||||
item.status === 'Pending' ? 'En Revisión' :
|
||||
'Borrador'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL DE GESTIÓN */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex gap-2">
|
||||
{['Vendido', 'Reservado', 'Alquilado'].map(status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleUpdateOverlay(item.id, item.overlayStatus === status ? null : status)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-xl text-[9px] font-black uppercase transition-all border",
|
||||
item.overlayStatus === status
|
||||
? "bg-slate-900 text-white border-slate-900 shadow-lg scale-105"
|
||||
: "bg-white text-slate-400 border-slate-200 hover:border-slate-400"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setRepublishTarget(item)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-xl text-[9px] font-black uppercase shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95"
|
||||
>
|
||||
<RefreshCcw size={12} /> Republicar aviso
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* TAB: SEGURIDAD / MFA */}
|
||||
{activeTab === 'security' && (
|
||||
<motion.div key="security" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<h2 className="text-2xl font-black text-slate-900 mb-2 uppercase tracking-tighter">Protección de Cuenta</h2>
|
||||
<p className="text-slate-500 text-sm mb-10">Gestiona la seguridad de tu acceso y la verificación en dos pasos.</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Card MFA */}
|
||||
<div className="p-8 rounded-[2rem] bg-slate-50 border border-slate-100 relative overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start relative z-10">
|
||||
<div className="bg-white p-4 rounded-3xl shadow-sm border border-slate-100">
|
||||
<QrCode size={40} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-black text-slate-900 uppercase text-base mb-1">Doble Factor de Autenticación (TOTP)</h4>
|
||||
<p className="text-slate-500 text-xs leading-relaxed mb-6">Añade una capa extra de seguridad usando Google Authenticator o similares.</p>
|
||||
|
||||
{!mfaSetupData ? (
|
||||
<button
|
||||
onClick={handleSetupMfa}
|
||||
className="bg-slate-900 text-white px-8 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest hover:bg-blue-600 transition-all shadow-lg shadow-slate-200"
|
||||
>
|
||||
Configurar MFA ahora
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-white p-6 rounded-3xl border border-blue-100 animate-in fade-in slide-in-from-bottom-2">
|
||||
<p className="text-[10px] font-black text-blue-600 uppercase mb-4 text-center">1. Escanea este código</p>
|
||||
<div className="bg-white p-4 rounded-2xl w-fit mx-auto border border-slate-100 mb-6">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(mfaSetupData.qrCodeUri)}`}
|
||||
alt="QR"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase mb-3 text-center">2. Introduce el código de 6 dígitos</p>
|
||||
<div className="flex gap-3 max-w-xs mx-auto">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
className="flex-1 bg-slate-50 border-none rounded-xl text-center font-mono font-bold text-xl py-3 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleVerifyAndEnableMfa}
|
||||
className="bg-blue-600 text-white px-6 rounded-xl font-black uppercase text-[10px] hover:bg-blue-700"
|
||||
>
|
||||
Activar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Password Change */}
|
||||
<div className="p-8 rounded-[2rem] border border-slate-100 flex items-center justify-between group hover:bg-slate-50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-slate-100 rounded-2xl text-slate-400 group-hover:text-slate-900 group-hover:bg-white transition-all shadow-sm">
|
||||
<Lock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-slate-900 uppercase text-sm">Cambiar Contraseña</h4>
|
||||
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-widest mt-1">Último cambio: Hace 3 meses</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-slate-300 group-hover:text-slate-900 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* TAB: AJUSTES */}
|
||||
{activeTab === 'settings' && (
|
||||
<motion.div key="settings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Ajustes de cuenta</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
|
||||
<InputGroup label="Email de contacto" value={user?.email || 'admin@sigcm.com'} />
|
||||
<InputGroup label="Teléfono / WhatsApp" placeholder="+54..." />
|
||||
<InputGroup label="Ubicación" value="La Plata, Buenos Aires" />
|
||||
</div>
|
||||
<div className="mt-10 pt-10 border-t border-slate-50 flex justify-end">
|
||||
<button className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all">
|
||||
Guardar cambios
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
{/* MODAL DE CONFIRMACIÓN */}
|
||||
<AnimatePresence>
|
||||
{republishTarget && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="relative bg-white p-8 rounded-[2.5rem] shadow-2xl max-w-sm w-full text-center border border-slate-100"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<RefreshCcw size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-3">Republicar Aviso</h3>
|
||||
<p className="text-sm text-slate-500 font-medium mb-8 leading-relaxed">
|
||||
Se creará una nueva publicación basada en <strong>{republishTarget.title}</strong>. Podrás editar los datos antes de realizar el pago.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="flex-1 py-4 px-6 bg-slate-100 text-slate-500 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-slate-200 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmRepublish}
|
||||
className="flex-1 py-4 px-6 bg-blue-600 text-white font-black uppercase text-[10px] tracking-widest rounded-2xl shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
// COMPONENTES AUXILIARES PARA LIMPIEZA
|
||||
function SidebarItem({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
|
||||
? 'bg-blue-600 text-white shadow-xl shadow-blue-200'
|
||||
: 'text-slate-400 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroup({ label, value, disabled, placeholder }: any) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className={`w-full px-6 py-4 bg-slate-50 border-none rounded-2xl focus:ring-2 focus:ring-blue-100 outline-none transition-all font-bold text-slate-700 ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-100'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/public-web/src/pages/PublishFeedback.tsx
Normal file
54
frontend/public-web/src/pages/PublishFeedback.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { CheckCircle2, XCircle, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function PublishFeedback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const status = searchParams.get('status'); // Viene de Mercado Pago
|
||||
|
||||
const isSuccess = status === 'approved';
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white p-12 rounded-[3rem] shadow-2xl border border-slate-100 max-w-md w-full text-center"
|
||||
>
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<div className="bg-emerald-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 text-emerald-500">
|
||||
<CheckCircle2 size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter">¡Publicado!</h2>
|
||||
<p className="text-slate-500 mt-4 font-medium italic">
|
||||
Tu pago fue aprobado. Tu aviso ya está en proceso de revisión/publicación.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-rose-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 text-rose-500">
|
||||
<XCircle size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter">Hubo un problema</h2>
|
||||
<p className="text-slate-500 mt-4 font-medium italic">
|
||||
No pudimos procesar el pago. Por favor, intenta de nuevo desde tu perfil.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="w-full py-4 bg-slate-900 text-white rounded-2xl font-black uppercase text-xs tracking-widest flex items-center justify-center gap-2 hover:bg-black transition-all"
|
||||
>
|
||||
Ir a mis publicaciones <ArrowRight size={16} />
|
||||
</Link>
|
||||
<Link to="/" className="block text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-blue-600 transition-colors">
|
||||
Volver al inicio
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/public-web/src/pages/PublishPage.tsx
Normal file
60
frontend/public-web/src/pages/PublishPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useWizardStore } from '../store/wizardStore';
|
||||
import CategorySelection from './Steps/CategorySelection';
|
||||
import OperationSelection from './Steps/OperationSelection';
|
||||
import AttributeForm from './Steps/AttributeForm';
|
||||
import TextEditorStep from './Steps/TextEditorStep';
|
||||
import PhotoUploadStep from './Steps/PhotoUploadStep';
|
||||
import SummaryStep from './Steps/SummaryStep';
|
||||
import { wizardService } from '../services/wizardService';
|
||||
import type { AttributeDefinition } from '../types';
|
||||
import SEO from '../components/SEO';
|
||||
|
||||
export default function PublishPage() {
|
||||
const { step, selectedCategory } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// CAMBIO: Agregamos el guard aquí también
|
||||
if (selectedCategory?.id) {
|
||||
wizardService.getAttributes(selectedCategory.id)
|
||||
.then(setDefinitions)
|
||||
.catch(err => console.error("Error en PublishPage:", err));
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
|
||||
<SEO
|
||||
title="Publicar Aviso"
|
||||
description="Publica tu aviso clasificado en simples pasos."
|
||||
/>
|
||||
|
||||
{/* Header del Wizard interno */}
|
||||
<div className="bg-white border-b border-slate-200 sticky top-[80px] z-30 shadow-sm">
|
||||
<div className="max-w-3xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Paso {step} de 6
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-8 h-1.5 rounded-full transition-colors ${i <= step ? 'bg-blue-600' : 'bg-slate-100'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-3xl mx-auto p-6 mt-8 bg-white rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-100">
|
||||
{step === 1 && <CategorySelection />}
|
||||
{step === 2 && <OperationSelection />}
|
||||
{step === 3 && <AttributeForm />}
|
||||
{step === 4 && <TextEditorStep />}
|
||||
{step === 5 && <PhotoUploadStep />}
|
||||
{step === 6 && <SummaryStep definitions={definitions} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
frontend/public-web/src/pages/Steps/AttributeForm.tsx
Normal file
134
frontend/public-web/src/pages/Steps/AttributeForm.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { ChevronRight, Info, Tag, PenTool, DollarSign } from 'lucide-react';
|
||||
|
||||
export default function AttributeForm() {
|
||||
const { selectedCategory, selectedOperation, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// CAMBIO: Verificamos específicamente que exista el ID para evitar el GET /undefined
|
||||
if (selectedCategory?.id) {
|
||||
wizardService.getAttributes(selectedCategory.id)
|
||||
.then(setDefinitions)
|
||||
.catch(err => console.error("Error al obtener atributos:", err));
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-3 mb-8 px-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
<button onClick={() => setStep(1)} className="hover:text-blue-600 transition-colors">
|
||||
{selectedCategory?.name}
|
||||
</button>
|
||||
<ChevronRight size={10} className="opacity-30" />
|
||||
<button onClick={() => setStep(2)} className="hover:text-blue-600 transition-colors">
|
||||
{selectedOperation?.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Características <br />
|
||||
<span className="text-blue-600">del Anuncio</span>
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-3 font-medium">Completa los datos técnicos para mejorar la visibilidad.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* GRUPO: INFORMACIÓN BÁSICA */}
|
||||
<div className="space-y-6 bg-white p-8 rounded-[2.5rem] border border-slate-100 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-2">
|
||||
<PenTool size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Información General</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Título del Aviso</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 focus:bg-white transition-all font-bold text-slate-700 text-sm"
|
||||
onChange={(e) => setAttribute('title', e.target.value)}
|
||||
value={attributes['title'] || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Precio de Venta</label>
|
||||
<div className="relative flex items-center">
|
||||
{/* Icono de moneda separado del texto */}
|
||||
<div className="absolute left-4 text-slate-400 font-black text-lg pointer-events-none">
|
||||
<DollarSign size={18} />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
/* Aumentamos pl-12 para evitar superposición */
|
||||
className="w-full p-4 pl-12 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 focus:bg-white transition-all font-mono font-black text-slate-800 text-lg"
|
||||
onChange={(e) => setAttribute('price', e.target.value)}
|
||||
value={attributes['price'] || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRUPO: ESPECIFICACIONES TÉCNICAS */}
|
||||
{definitions.length > 0 && (
|
||||
<div className="space-y-6 bg-slate-50/50 p-8 rounded-[2.5rem] border-2 border-slate-100">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-2">
|
||||
<Tag size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Ficha Técnica</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{definitions.map(def => (
|
||||
<div key={def.id} className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">
|
||||
{def.name} {def.required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type={def.dataType === 'number' ? 'number' : 'text'}
|
||||
required={def.required}
|
||||
placeholder={`Ingrese ${def.name.toLowerCase()}...`}
|
||||
className="w-full p-4 bg-white border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 transition-all font-bold text-slate-700 text-sm placeholder:text-slate-200 shadow-sm"
|
||||
value={attributes[def.name.toLowerCase()] || ''}
|
||||
onChange={(e) => setAttribute(def.name.toLowerCase(), e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 bg-blue-50 rounded-3xl flex gap-4 border border-blue-100 items-start">
|
||||
<Info className="text-blue-500 shrink-0" size={20} />
|
||||
<p className="text-[10px] text-blue-700 font-bold leading-relaxed uppercase opacity-80">
|
||||
Los datos cargados aquí sirven para filtrar las búsquedas en el portal digital.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BOTÓN CON ALTO CONTRASTE */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-slate-950 hover:bg-black text-white font-black uppercase text-xs tracking-[0.2em] py-6 rounded-[2rem] shadow-2xl shadow-slate-300 transition-all mt-6 active:scale-[0.98] flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Continuar al Editor de Texto
|
||||
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
107
frontend/public-web/src/pages/Steps/PhotoUploadStep.tsx
Normal file
107
frontend/public-web/src/pages/Steps/PhotoUploadStep.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { Upload, Plus, Trash2, ArrowLeft, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function PhotoUploadStep() {
|
||||
const { setStep, photos, existingImages, removePhoto, removeExistingImage, addPhoto } = useWizardStore();
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
acceptedFiles.forEach(file => addPhoto(file));
|
||||
}, [addPhoto]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': [] },
|
||||
maxFiles: 10
|
||||
});
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="mb-10 text-center">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Galería de <br />
|
||||
<span className="text-blue-600">Imágenes</span>
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-3 font-medium">Gestiona las fotos de tu aviso. La primera será la portada.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
|
||||
{/* GRILLA DE FOTOS (EXISTENTES + NUEVAS) */}
|
||||
{(existingImages.length > 0 || photos.length > 0) && (
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
{/* Renderizado de Fotos Heredadas */}
|
||||
{existingImages.map((img, idx) => (
|
||||
<div key={`old-${idx}`} className="group relative aspect-square bg-slate-100 rounded-[2rem] overflow-hidden border-2 border-slate-100 shadow-sm transition-all hover:shadow-xl">
|
||||
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" alt="Original" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button onClick={() => removeExistingImage(idx)} className="bg-rose-500 text-white p-3 rounded-2xl hover:bg-rose-600 transition-colors shadow-lg">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<div className="absolute top-3 left-3 bg-blue-600 text-white text-[8px] font-black uppercase px-2 py-1 rounded-lg shadow-lg">Portada</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Renderizado de Fotos Nuevas (Files) */}
|
||||
{photos.map((file, idx) => (
|
||||
<div key={`new-${idx}`} className="group relative aspect-square bg-blue-50 rounded-[2rem] overflow-hidden border-2 border-blue-200 shadow-sm transition-all hover:shadow-xl">
|
||||
<img src={URL.createObjectURL(file)} className="w-full h-full object-cover" alt="Nueva" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button onClick={() => removePhoto(idx)} className="bg-rose-500 text-white p-3 rounded-2xl shadow-lg">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{existingImages.length === 0 && idx === 0 && (
|
||||
<div className="absolute top-3 left-3 bg-blue-600 text-white text-[8px] font-black uppercase px-2 py-1 rounded-lg shadow-lg">Portada</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* BOTÓN "AÑADIR MÁS" dentro de la grilla si ya hay fotos */}
|
||||
<div {...getRootProps()} className="aspect-square bg-slate-50 border-2 border-dashed border-slate-200 rounded-[2rem] flex flex-col items-center justify-center cursor-pointer hover:bg-white hover:border-blue-400 transition-all group">
|
||||
<input {...getInputProps()} />
|
||||
<div className="p-3 bg-white rounded-2xl shadow-sm text-slate-400 group-hover:text-blue-500 group-hover:shadow-blue-100 transition-all">
|
||||
<Plus size={24} />
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase mt-2 group-hover:text-blue-600">Añadir más</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ÁREA DE CARGA INICIAL (Solo si está todo vacío) */}
|
||||
{existingImages.length === 0 && photos.length === 0 && (
|
||||
<section {...getRootProps()} className={clsx(
|
||||
"p-16 border-4 border-dashed rounded-[3rem] text-center transition-all cursor-pointer group",
|
||||
isDragActive ? "border-blue-500 bg-blue-50" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50"
|
||||
)}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-20 h-20 bg-blue-50 text-blue-600 rounded-[2rem] flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-2">Sube tus fotografías</h3>
|
||||
<p className="text-slate-400 text-xs font-medium max-w-xs mx-auto leading-relaxed">
|
||||
Arrastra tus fotos aquí o haz clic para buscarlas. Soporta JPG, PNG y WebP.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-6">
|
||||
<button onClick={() => setStep(4)} className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3">
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button onClick={() => setStep(6)} className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3">
|
||||
Continuar al Resumen <ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
291
frontend/public-web/src/pages/Steps/SummaryStep.tsx
Normal file
291
frontend/public-web/src/pages/Steps/SummaryStep.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState } from 'react';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import {
|
||||
CreditCard,
|
||||
Wallet,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Tag,
|
||||
FileText,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { initMercadoPago, Wallet as MPWallet } from '@mercadopago/sdk-react';
|
||||
import clsx from 'clsx';
|
||||
import api from '../../services/api';
|
||||
|
||||
// Se inicializa con la Public Key desde env
|
||||
initMercadoPago(import.meta.env.VITE_MP_PUBLIC_KEY);
|
||||
|
||||
export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) {
|
||||
const { selectedCategory, selectedOperation, attributes, photos, setStep, reset } = useWizardStore();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [preferenceId, setPreferenceId] = useState<string | null>(null);
|
||||
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'mercadopago' | 'stripe'>('mercadopago');
|
||||
|
||||
const adFee = parseFloat(attributes['adFee'] || "0");
|
||||
const listingPrice = parseFloat(attributes['price'] || "0");
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedCategory || !selectedOperation) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const attributePayload: Record<number, string> = {};
|
||||
const { existingImages } = useWizardStore.getState();
|
||||
|
||||
definitions.forEach(def => {
|
||||
const val = attributes[def.name.toLowerCase()] || attributes[def.name];
|
||||
if (val) {
|
||||
attributePayload[def.id] = val.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const payload = {
|
||||
categoryId: selectedCategory.id,
|
||||
operationId: selectedOperation.id,
|
||||
title: attributes['title'],
|
||||
description: attributes['description'],
|
||||
price: parseFloat(attributes['price'] || "0"),
|
||||
adFee: adFee,
|
||||
currency: 'ARS',
|
||||
status: 'Pending',
|
||||
origin: 'Web',
|
||||
attributes: attributePayload,
|
||||
imagesToClone: existingImages.map(img => img.url),
|
||||
printText: attributes['description'],
|
||||
printDaysCount: parseInt(attributes['days'] || "3"),
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
printFontSize: 'normal',
|
||||
printAlignment: 'left'
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
setCreatedId(result.id);
|
||||
|
||||
if (photos.length > 0) {
|
||||
for (const photo of photos) {
|
||||
await wizardService.uploadImage(result.id, photo);
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(`${import.meta.env.VITE_API_URL}/payments/create-preference/${result.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(adFee)
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.id) {
|
||||
setPreferenceId(data.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al publicar');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paymentStatus = urlParams.get('status');
|
||||
|
||||
if (paymentStatus === 'approved') {
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="text-center py-20 bg-white rounded-[3rem] shadow-2xl p-12 border border-emerald-100">
|
||||
<div className="w-24 h-24 bg-blue-50 text-blue-500 rounded-full flex items-center justify-center mx-auto mb-8 shadow-inner">
|
||||
<Clock size={64} strokeWidth={1.5} className="animate-pulse" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-black mb-4 text-slate-900 tracking-tighter uppercase">¡Pago Recibido!</h2>
|
||||
<p className="text-slate-500 text-lg mb-10 font-medium max-w-md mx-auto">
|
||||
Tu aviso ha sido enviado a nuestro equipo de **Moderación**. Una vez aprobado, se publicará automáticamente en el portal y en la edición impresa.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 max-w-xs mx-auto">
|
||||
<button
|
||||
onClick={() => { reset(); window.location.href = '/profile'; }}
|
||||
className="bg-slate-900 text-white px-12 py-5 rounded-2xl font-black uppercase text-xs tracking-[0.2em] hover:bg-black transition-all shadow-xl shadow-slate-200"
|
||||
>
|
||||
Ver estado en mi Perfil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (preferenceId) {
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="text-center py-20 bg-white rounded-[3rem] shadow-2xl p-12 border border-blue-100">
|
||||
<div className="w-24 h-24 bg-blue-50 text-blue-600 rounded-full flex items-center justify-center mx-auto mb-8">
|
||||
<Wallet size={56} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black mb-4 text-slate-900 tracking-tighter uppercase">Finalizar Publicación</h2>
|
||||
|
||||
{/* LÓGICA DE SIMULACIÓN */}
|
||||
{preferenceId === "MOCK_PREFERENCE_ID_12345" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-amber-50 border border-amber-100 rounded-2xl text-amber-700 text-xs font-bold uppercase">
|
||||
Modo Simulación Activo: Token de MP no configurado.
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mb-8 font-medium max-w-sm mx-auto">
|
||||
Estás en el entorno de desarrollo. Haz clic abajo para simular un pago aprobado y finalizar el proceso.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Enviamos un objeto en lugar del número solo
|
||||
await api.post(`/payments/confirm-simulation/${createdId}`, {
|
||||
amount: adFee
|
||||
});
|
||||
|
||||
// Si la petición es exitosa, procedemos a la pantalla de éxito
|
||||
window.location.href = window.location.pathname + '?status=approved';
|
||||
} catch (error) {
|
||||
console.error("Error en la simulación:", error);
|
||||
alert("No se pudo confirmar el pago simulado.");
|
||||
}
|
||||
}}
|
||||
className="w-full py-5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-emerald-200 transition-all"
|
||||
>
|
||||
Simular Pago Aprobado
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// LÓGICA REAL
|
||||
<>
|
||||
<p className="text-slate-500 text-lg mb-10 font-medium max-w-sm mx-auto leading-tight">
|
||||
Haz clic abajo para completar el pago de <strong>${adFee.toLocaleString()}</strong> de forma segura con Mercado Pago.
|
||||
</p>
|
||||
<div className="max-w-xs mx-auto scale-110">
|
||||
<MPWallet initialization={{ preferenceId }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="mb-10 text-center">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-400 mb-4 block">Paso Final</span>
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Resumen de <br />
|
||||
<span className="text-blue-600">tu publicación</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
|
||||
{/* RESUMEN DEL AVISO */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<div className="flex justify-between items-start mb-8 pb-6 border-b border-slate-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-50 rounded-2xl text-slate-400"><FileText size={20} /></div>
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 uppercase text-sm tracking-tight">{attributes['title']}</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{selectedCategory?.name} / {selectedOperation?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase block mb-1">Precio Producto</span>
|
||||
<p className="text-xl font-black text-slate-900">${listingPrice.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-10 gap-y-4 mb-4">
|
||||
{definitions.map(def => {
|
||||
const val = attributes[def.name.toLowerCase()] || attributes[def.name];
|
||||
return val && (
|
||||
<div key={def.id} className="flex justify-between items-center group">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase group-hover:text-slate-600 transition-colors">{def.name}</span>
|
||||
<span className="text-xs font-black text-slate-800 uppercase">{val}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- BLOQUE DE COSTO (TOTAL A PAGAR) --- */}
|
||||
<section className="bg-blue-600 rounded-[2.5rem] p-8 text-white shadow-2xl shadow-blue-200 relative overflow-hidden">
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] opacity-60">Total a Pagar</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-black">$</span>
|
||||
<span className="text-5xl font-black font-mono tracking-tighter">
|
||||
{adFee.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-[1.5rem] border border-white/20 backdrop-blur-sm">
|
||||
<Tag size={24} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* MÉTODO DE PAGO */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-4">Selecciona Método de Pago</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className={clsx(
|
||||
"flex items-center gap-4 p-6 rounded-3xl border-2 cursor-pointer transition-all",
|
||||
paymentMethod === 'mercadopago' ? "border-blue-600 bg-blue-50 shadow-lg shadow-blue-100" : "border-slate-100 bg-white hover:border-blue-200"
|
||||
)}>
|
||||
<input type="radio" className="hidden" checked={paymentMethod === 'mercadopago'} onChange={() => setPaymentMethod('mercadopago')} />
|
||||
<div className={clsx("p-3 rounded-2xl", paymentMethod === 'mercadopago' ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400")}>
|
||||
<Wallet size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-xs">Mercado Pago</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase">QR, Débito, Crédito</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={clsx(
|
||||
"flex items-center gap-4 p-6 rounded-3xl border-2 cursor-pointer transition-all",
|
||||
paymentMethod === 'stripe' ? "border-slate-900 bg-slate-50 shadow-lg shadow-slate-100" : "border-slate-100 bg-white hover:border-slate-200"
|
||||
)}>
|
||||
<input type="radio" className="hidden" checked={paymentMethod === 'stripe'} onChange={() => setPaymentMethod('stripe')} />
|
||||
<div className={clsx("p-3 rounded-2xl", paymentMethod === 'stripe' ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-400")}>
|
||||
<CreditCard size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-xs">Tarjeta Directa</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase">Visa o MasterCard</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ACCIÓN PRINCIPAL */}
|
||||
<div className="flex flex-col md:flex-row gap-4 pt-6">
|
||||
<button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isSubmitting}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-500/20 hover:bg-blue-700 disabled:opacity-50 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
<span className="text-white">
|
||||
{isSubmitting ? 'Procesando...' : 'Pagar y Publicar Aviso'}
|
||||
</span>
|
||||
<ChevronRight size={18} className="text-blue-300 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
233
frontend/public-web/src/pages/Steps/TextEditorStep.tsx
Normal file
233
frontend/public-web/src/pages/Steps/TextEditorStep.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { Eye, FileText, Calendar, ChevronRight, ArrowLeft } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface PricingResult {
|
||||
totalPrice: number;
|
||||
baseCost: number;
|
||||
extraCost: number;
|
||||
wordCount: number;
|
||||
specialCharCount: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export default function TextEditorStep() {
|
||||
const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [text, setText] = useState(attributes['description'] || '');
|
||||
const [days, setDays] = useState(parseInt(attributes['days'] as string) || 3);
|
||||
const [pricing, setPricing] = useState<PricingResult>({
|
||||
totalPrice: 0,
|
||||
baseCost: 0,
|
||||
extraCost: 0,
|
||||
wordCount: 0,
|
||||
specialCharCount: 0,
|
||||
details: ''
|
||||
});
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
|
||||
const debouncedText = useDebounce(text, 800);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategory || debouncedText.length === 0) {
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatePrice = async () => {
|
||||
setLoadingPrice(true);
|
||||
try {
|
||||
const response = await api.post('/pricing/calculate', {
|
||||
categoryId: selectedCategory.id,
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
startDate: new Date().toISOString()
|
||||
});
|
||||
setPricing(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error al calcular precio:', error);
|
||||
} finally {
|
||||
setLoadingPrice(false);
|
||||
}
|
||||
};
|
||||
|
||||
calculatePrice();
|
||||
}, [debouncedText, days, selectedCategory]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (text.trim().length === 0) {
|
||||
alert('⚠️ Debes escribir el texto del aviso');
|
||||
return;
|
||||
}
|
||||
setAttribute('description', text);
|
||||
setAttribute('days', days.toString());
|
||||
setAttribute('adFee', pricing.totalPrice.toString());
|
||||
setStep(5); // Siguiente paso
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
{/* Header & Breadcrumb */}
|
||||
<div className="mb-10 text-center">
|
||||
<div className="flex justify-center items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-slate-400 mb-4">
|
||||
<span>{selectedCategory?.name}</span>
|
||||
<ChevronRight size={12} />
|
||||
<span className="text-blue-600">Redacción</span>
|
||||
</div>
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Escribe tu <br />
|
||||
<span className="text-blue-600">Aviso Clasificado</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto space-y-10">
|
||||
|
||||
{/* BLOQUE 1: EL EDITOR */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<div className="flex items-center gap-3 mb-6 text-slate-900">
|
||||
<div className="p-2 bg-blue-50 rounded-xl text-blue-600">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<h3 className="font-black uppercase tracking-widest text-xs">Contenido del Aviso</h3>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full h-56 p-6 bg-slate-50 border-2 border-slate-100 rounded-[2rem] outline-none focus:border-blue-500 focus:bg-white transition-all font-mono text-lg leading-relaxed text-slate-700 placeholder:text-slate-300"
|
||||
placeholder="Comienza a escribir aquí..."
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-6 px-2">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Palabras</span>
|
||||
<span className={clsx("text-xl font-black", pricing.wordCount > 0 ? "text-blue-600" : "text-slate-300")}>
|
||||
{pricing.wordCount.toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
{pricing.specialCharCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Signos</span>
|
||||
<span className="text-xl font-black text-orange-500">
|
||||
{pricing.specialCharCount.toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loadingPrice && (
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase animate-pulse">
|
||||
Sincronizando tarifa...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE DÍAS */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración de la oferta</h4>
|
||||
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-wider">¿Cuántos días se publicará?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-slate-100 rounded-2xl p-1 w-full md:w-48">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-16 text-center bg-transparent border-none outline-none font-black text-xl text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 3: PREVISUALIZACIÓN */}
|
||||
<section className="bg-[#fffef5] p-8 rounded-[2.5rem] border-2 border-yellow-100 shadow-inner relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Eye size={120} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 text-yellow-700 relative z-10">
|
||||
<Eye size={18} />
|
||||
<h3 className="font-black uppercase tracking-[0.2em] text-[10px]">Vista Previa Real</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[1.8rem] border border-yellow-200 shadow-sm min-h-[10rem] relative z-10">
|
||||
{text ? (
|
||||
<p className="text-slate-800 text-lg font-medium leading-relaxed italic uppercase">
|
||||
{text}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-300 font-bold text-center py-10 uppercase tracking-widest text-xs">
|
||||
El texto aparecerá aquí...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 4: COSTO FINAL */}
|
||||
<section className="bg-slate-900 rounded-[3rem] p-10 text-white shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full blur-[80px] -mr-32 -mt-32"></div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
|
||||
<div className="text-center md:text-left">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 mb-2 block">Total a Pagar</span>
|
||||
<div className="flex items-baseline justify-center md:justify-start gap-2">
|
||||
<span className="text-2xl font-black text-blue-500">$</span>
|
||||
<span className="text-6xl font-black tracking-tighter font-mono">
|
||||
{pricing.totalPrice.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64 space-y-3 bg-white/5 p-6 rounded-3xl border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase">
|
||||
<span className="text-slate-500">Tarifa base</span>
|
||||
<span className="text-white">${pricing.baseCost.toLocaleString()}</span>
|
||||
</div>
|
||||
{pricing.extraCost > 0 && (
|
||||
<div className="flex justify-between text-[10px] font-black uppercase text-orange-400">
|
||||
<span>Recargo texto</span>
|
||||
<span>+${pricing.extraCost.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-px bg-white/10 my-2"></div>
|
||||
<p className="text-[9px] text-slate-500 font-bold leading-tight italic">
|
||||
* {pricing.details || "Calculando tasas e impuestos..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BOTONES DE NAVEGACIÓN */}
|
||||
<div className="flex flex-col md:flex-row gap-4 pt-6">
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={text.trim().length === 0 || loadingPrice}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-500/20 hover:bg-blue-700 disabled:opacity-50 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Continuar a Fotos
|
||||
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL, // Usa la variable de entorno
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('public_token');
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
}, error => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
export default api;
|
||||
67
frontend/public-web/src/services/authService.ts
Normal file
67
frontend/public-web/src/services/authService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const API_URL = `${import.meta.env.VITE_API_URL}/auth`;
|
||||
|
||||
export const publicAuthService = {
|
||||
login: async (username: string, password: string) => {
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.token) {
|
||||
localStorage.setItem('public_token', data.token);
|
||||
localStorage.setItem('public_user', JSON.stringify({ username }));
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string) => {
|
||||
const response = await fetch(`${API_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password })
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
googleLogin: async (idToken: string) => {
|
||||
const response = await fetch(`${API_URL}/google-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(idToken)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.token) {
|
||||
localStorage.setItem('public_token', data.token);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
setupMfa: async () => {
|
||||
const token = localStorage.getItem('public_token');
|
||||
const response = await fetch(`${API_URL}/mfa/setup`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
verifyMfa: async (code: string) => {
|
||||
const token = localStorage.getItem('public_token');
|
||||
const response = await fetch(`${API_URL}/mfa/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(code)
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('public_token');
|
||||
localStorage.removeItem('public_user');
|
||||
},
|
||||
|
||||
isAuthenticated: () => !!localStorage.getItem('public_token')
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import api from './api';
|
||||
import type { Category, Operation, AttributeDefinition } from '../types'; // ID: type-import-fix
|
||||
import type {
|
||||
Category,
|
||||
Operation,
|
||||
AttributeDefinition,
|
||||
CreateListingDto
|
||||
} from '../types';
|
||||
|
||||
export const wizardService = {
|
||||
getCategories: async (): Promise<Category[]> => {
|
||||
@@ -17,7 +22,7 @@ export const wizardService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createListing: async (data: any): Promise<{ id: number }> => {
|
||||
createListing: async (data: CreateListingDto): Promise<{ id: number }> => {
|
||||
const response = await api.post<{ id: number }>('/listings', data);
|
||||
return response.data;
|
||||
},
|
||||
@@ -30,4 +35,4 @@ export const wizardService = {
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
};
|
||||
27
frontend/public-web/src/store/publicAuthStore.ts
Normal file
27
frontend/public-web/src/store/publicAuthStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface PublicAuthState {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const usePublicAuthStore = create<PublicAuthState>((set) => ({
|
||||
// Inicializamos con lo que haya en localStorage
|
||||
user: localStorage.getItem('public_user')
|
||||
? JSON.parse(localStorage.getItem('public_user')!)
|
||||
: null,
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('public_token');
|
||||
localStorage.removeItem('public_user');
|
||||
set({ user: null });
|
||||
}
|
||||
}));
|
||||
117
frontend/public-web/src/store/wizardStore.ts
Normal file
117
frontend/public-web/src/store/wizardStore.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { Category, Operation, Listing } from '../types';
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
selectedCategory: Category | null;
|
||||
selectedOperation: Operation | null;
|
||||
attributes: Record<string, string>;
|
||||
photos: File[];
|
||||
setStep: (step: number) => void;
|
||||
setCategory: (category: Category) => void;
|
||||
setOperation: (operation: Operation) => void;
|
||||
setAttribute: (key: string, value: string) => void;
|
||||
addPhoto: (file: File) => void;
|
||||
removePhoto: (index: number) => void;
|
||||
setRepublishData: (listing: Listing) => void;
|
||||
reset: () => void;
|
||||
existingImages: any[];
|
||||
removeExistingImage: (index: number) => void;
|
||||
}
|
||||
|
||||
export const useWizardStore = create<WizardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
step: 1,
|
||||
selectedCategory: null,
|
||||
selectedOperation: null,
|
||||
attributes: {},
|
||||
photos: [],
|
||||
existingImages: [],
|
||||
|
||||
setStep: (step) => set({ step }),
|
||||
|
||||
setCategory: (category) => set({ selectedCategory: category, step: 2 }),
|
||||
|
||||
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }),
|
||||
|
||||
setAttribute: (key, value) => set((state) => ({
|
||||
attributes: { ...state.attributes, [key]: value }
|
||||
})),
|
||||
|
||||
addPhoto: (file) => set((state) => ({ photos: [...state.photos, file] })),
|
||||
|
||||
removePhoto: (index) => set((state) => ({ photos: state.photos.filter((_, i) => i !== index) })),
|
||||
|
||||
// REPUBLICACIÓN
|
||||
setRepublishData: (data: any) => {
|
||||
const listing = data.listing || data.Listing || data;
|
||||
|
||||
// Extraemos el ID con soporte para ambos casos
|
||||
const catId = listing.categoryId ?? listing.CategoryId;
|
||||
|
||||
if (!catId) {
|
||||
console.error("Error crítico: No se encontró CategoryId en:", data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Mapeamos campos estáticos (siempre en minúsculas para el form)
|
||||
const mappedAttributes: Record<string, string> = {
|
||||
title: listing.title || listing.Title || '',
|
||||
description: listing.description || listing.Description || '',
|
||||
price: (listing.price || listing.Price || '0').toString(),
|
||||
};
|
||||
|
||||
// 2. Mapeamos atributos dinámicos
|
||||
const attributesArray = data.attributes || data.Attributes || [];
|
||||
if (Array.isArray(attributesArray)) {
|
||||
attributesArray.forEach((attr: any) => {
|
||||
// Normalizamos la clave del atributo a minúsculas
|
||||
const rawKey = attr.attributeName || attr.AttributeName;
|
||||
if (rawKey) {
|
||||
const key = rawKey.toLowerCase();
|
||||
mappedAttributes[key] = (attr.value || attr.Value || '').toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const imagesArray = data.images || data.Images || [];
|
||||
|
||||
set({
|
||||
step: 3,
|
||||
selectedCategory: {
|
||||
id: catId,
|
||||
name: listing.categoryName || listing.CategoryName || 'Rubro'
|
||||
} as any,
|
||||
selectedOperation: {
|
||||
id: listing.operationId || listing.OperationId,
|
||||
name: 'Venta'
|
||||
} as any,
|
||||
attributes: mappedAttributes,
|
||||
existingImages: imagesArray,
|
||||
photos: []
|
||||
});
|
||||
},
|
||||
|
||||
removeExistingImage: (index: number) => set((state) => ({
|
||||
existingImages: state.existingImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
|
||||
reset: () => {
|
||||
localStorage.removeItem('wizard-storage');
|
||||
set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {}, photos: [], existingImages: [] });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'wizard-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
step: state.step,
|
||||
selectedCategory: state.selectedCategory,
|
||||
selectedOperation: state.selectedOperation,
|
||||
attributes: state.attributes,
|
||||
}),
|
||||
},
|
||||
)
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
// --- Tipos del Portal ---
|
||||
export interface Listing {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -8,7 +9,11 @@ export interface Listing {
|
||||
operationId: number;
|
||||
createdAt: string;
|
||||
mainImageUrl?: string;
|
||||
categoryName?: string;
|
||||
status: string;
|
||||
images?: ListingImage[];
|
||||
viewCount: number;
|
||||
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
|
||||
}
|
||||
|
||||
export interface ListingAttribute {
|
||||
@@ -33,9 +38,43 @@ export interface ListingDetail {
|
||||
images: ListingImage[];
|
||||
}
|
||||
|
||||
// --- Tipos Agregados del Wizard ---
|
||||
export interface Category {
|
||||
id: number;
|
||||
parentId?: number | null;
|
||||
name: string;
|
||||
parentId?: number;
|
||||
slug: string;
|
||||
subcategories?: Category[];
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AttributeDefinition {
|
||||
id: number;
|
||||
name: string;
|
||||
dataType: 'text' | 'number' | 'boolean' | 'date';
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
// DTO para creación
|
||||
export interface CreateListingDto {
|
||||
categoryId: number;
|
||||
operationId: number;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
adFee: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
attributes: Record<number, string>;
|
||||
printText: string;
|
||||
printDaysCount: number;
|
||||
isBold?: boolean;
|
||||
isFrame?: boolean;
|
||||
printFontSize?: string;
|
||||
printAlignment?: string;
|
||||
imagesToClone?: string[];
|
||||
}
|
||||
@@ -23,9 +23,8 @@ export const processCategoriesForSelect = (rawCategories: Category[]): FlatCateg
|
||||
const map = new Map<number, CategoryNode>();
|
||||
const roots: CategoryNode[] = [];
|
||||
|
||||
// 1. Map
|
||||
// 1. Mapear categorías
|
||||
rawCategories.forEach(cat => {
|
||||
// @ts-ignore
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
|
||||
|
||||
24
frontend/publish-wizard/.gitignore
vendored
24
frontend/publish-wizard/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>publish-wizard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4313
frontend/publish-wizard/package-lock.json
generated
4313
frontend/publish-wizard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "publish-wizard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"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-dropzone": "^14.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useWizardStore } from './store/wizardStore';
|
||||
import CategorySelection from './pages/Steps/CategorySelection';
|
||||
import OperationSelection from './pages/Steps/OperationSelection';
|
||||
import AttributeForm from './pages/Steps/AttributeForm';
|
||||
import PhotoUploadStep from './pages/Steps/PhotoUploadStep';
|
||||
import SummaryStep from './pages/Steps/SummaryStep';
|
||||
import { wizardService } from './services/wizardService';
|
||||
import type { AttributeDefinition } from './types';
|
||||
|
||||
function App() {
|
||||
const { step, selectedCategory } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
wizardService.getAttributes(selectedCategory.id).then(setDefinitions);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-10">
|
||||
<div className="max-w-3xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div className="font-bold text-xl tracking-tight text-brand-600">
|
||||
SIG-CM <span className="text-slate-400 font-normal text-sm ml-2">Wizard de Publicación</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-500">
|
||||
Paso {step} de 5
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1 bg-slate-100 w-full">
|
||||
<div
|
||||
className="h-full bg-brand-500 transition-all duration-500 ease-out"
|
||||
style={{ width: `${(step / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto p-4 py-8">
|
||||
{step === 1 && <CategorySelection />}
|
||||
{step === 2 && <OperationSelection />}
|
||||
{step === 3 && <AttributeForm />}
|
||||
{step === 4 && <PhotoUploadStep />}
|
||||
{step === 5 && <SummaryStep definitions={definitions} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,16 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--color-brand-50: #f0f9ff;
|
||||
--color-brand-100: #e0f2fe;
|
||||
--color-brand-500: #0ea5e9;
|
||||
--color-brand-600: #0284c7;
|
||||
--color-brand-900: #0c4a6e;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 font-sans;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
|
||||
export default function AttributeForm() {
|
||||
const { selectedCategory, selectedOperation, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
wizardService.getAttributes(selectedCategory.id).then(setDefinitions);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="flex items-center gap-2 mb-6 text-sm">
|
||||
<button onClick={() => setStep(1)} className="text-slate-500 hover:text-brand-600">{selectedCategory?.name}</button>
|
||||
<span className="text-slate-300">/</span>
|
||||
<button onClick={() => setStep(2)} className="text-slate-500 hover:text-brand-600">{selectedOperation?.name}</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-6 text-brand-900">Características</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* Static Fields Example */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Título del Aviso</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
onChange={(e) => setAttribute('title', e.target.value)}
|
||||
value={attributes['title'] || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Precio</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-3 text-slate-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
className="w-full p-3 pl-8 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
onChange={(e) => setAttribute('price', e.target.value)}
|
||||
value={attributes['price'] || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Fields */}
|
||||
{definitions.map(def => (
|
||||
<div key={def.id}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{def.name} {def.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{def.dataType === 'boolean' ? (
|
||||
<select
|
||||
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none bg-white"
|
||||
onChange={(e) => setAttribute(def.name, e.target.value)}
|
||||
value={attributes[def.name] || ''}
|
||||
required={def.required}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="true">Sí</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={def.dataType === 'number' ? 'number' : 'text'}
|
||||
required={def.required}
|
||||
className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
onChange={(e) => setAttribute(def.name, e.target.value)}
|
||||
value={attributes[def.name] || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-bold py-4 rounded-xl shadow-lg shadow-brand-200 transition-all mt-8"
|
||||
>
|
||||
Continuar a Fotos
|
||||
</button>
|
||||
</form>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
export default function PhotoUploadStep() {
|
||||
const { setStep } = useWizardStore();
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<h2 className="text-2xl font-bold mb-2 text-brand-900">Fotos del Aviso</h2>
|
||||
<p className="text-slate-500 mb-6">Muestra lo mejor de tu producto. Primera foto es la portada.</p>
|
||||
|
||||
<DropzoneArea />
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
onClick={() => setStep(5)}
|
||||
className="bg-brand-600 text-white px-6 py-3 rounded-lg font-bold hover:bg-brand-700"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function DropzoneArea() {
|
||||
const { addPhoto, removePhoto, photos } = useWizardStore();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
acceptedFiles.forEach(file => {
|
||||
addPhoto(file);
|
||||
});
|
||||
}, [addPhoto]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': [] },
|
||||
maxFiles: 10
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors
|
||||
${isDragActive ? 'border-brand-500 bg-brand-50' : 'border-slate-300 hover:border-brand-400'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-2 text-slate-500">
|
||||
<Upload size={40} className="text-slate-400" />
|
||||
<p>Arrastra fotos aquí, o click para seleccionar</p>
|
||||
<p className="text-xs">Soporta JPG, PNG, WEBP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previews */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-4 mt-6">
|
||||
{photos.map((file: File, index: number) => (
|
||||
<div key={index} className="relative aspect-square bg-slate-100 rounded-lg overflow-hidden border border-slate-200 group">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt="preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removePhoto(index)}
|
||||
className="absolute top-1 right-1 bg-black/50 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{index === 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-brand-600/80 text-white text-xs py-1 text-center">
|
||||
Portada
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import { CreditCard, Wallet } from 'lucide-react';
|
||||
|
||||
export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) {
|
||||
const { selectedCategory, selectedOperation, attributes, photos, setStep } = useWizardStore();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'mercadopago' | 'stripe'>('mercadopago');
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedCategory || !selectedOperation) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const attributePayload: Record<number, string> = {};
|
||||
definitions.forEach(def => {
|
||||
if (attributes[def.name]) {
|
||||
attributePayload[def.id] = attributes[def.name].toString();
|
||||
}
|
||||
});
|
||||
|
||||
// Crear aviso con estado PENDING (Esperando pago)
|
||||
const payload = {
|
||||
categoryId: selectedCategory.id,
|
||||
operationId: selectedOperation.id,
|
||||
title: attributes['title'],
|
||||
description: 'Generated via Wizard',
|
||||
price: parseFloat(attributes['price']),
|
||||
currency: 'ARS',
|
||||
status: 'Pending', // <-- Importante para moderación
|
||||
attributes: attributePayload
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
|
||||
// Upload Images
|
||||
if (photos.length > 0) {
|
||||
for (const photo of photos) {
|
||||
await wizardService.uploadImage(result.id, photo);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulación de Pago
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Fake network delay
|
||||
|
||||
setCreatedId(result.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al publicar');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (createdId) {
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="text-center py-10 bg-white rounded-xl shadow p-8 border border-green-100">
|
||||
<div className="text-6xl text-green-500 mb-4 animate-bounce">✓</div>
|
||||
<h2 className="text-3xl font-bold mb-2 text-gray-800">¡Pago Exitoso!</h2>
|
||||
<p className="text-gray-500 text-lg">Tu aviso #{createdId} ha sido enviado a moderación.</p>
|
||||
<div className="mt-8 p-4 bg-gray-50 rounded text-sm text-gray-600">
|
||||
Comprobante de pago: {paymentMethod === 'mercadopago' ? 'MP-123456789' : 'ST-987654321'}
|
||||
</div>
|
||||
<button onClick={() => window.location.reload()} className="mt-8 bg-brand-600 text-white px-6 py-3 rounded-lg font-bold hover:bg-brand-700 transition">
|
||||
Publicar otro aviso
|
||||
</button>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<h2 className="text-2xl font-bold mb-6 text-brand-900">Resumen y Pago</h2>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-6">
|
||||
<div className="flex justify-between items-center mb-4 border-b pb-4">
|
||||
<div>
|
||||
<span className="block text-xs text-slate-500 uppercase tracking-wide">Categoría</span>
|
||||
<span className="font-semibold text-lg">{selectedCategory?.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="block text-xs text-slate-500 uppercase tracking-wide">Operación</span>
|
||||
<span className="font-semibold text-lg">{selectedOperation?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold mb-2 text-xl">{attributes['title']}</h3>
|
||||
<div className="text-3xl font-bold text-green-600 mb-6">$ {attributes['price']}</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm bg-gray-50 p-4 rounded-lg">
|
||||
{definitions.map(def => attributes[def.name] && (
|
||||
<div key={def.id}>
|
||||
<span className="text-slate-500 font-medium">{def.name}:</span> <span className="text-slate-800">{attributes[def.name]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selector de Pago */}
|
||||
<div className="mb-8">
|
||||
<h3 className="font-bold text-gray-800 mb-3 text-lg">Selecciona Método de Pago</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${paymentMethod === 'mercadopago' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-200'}`}>
|
||||
<input type="radio" name="payment" value="mercadopago" checked={paymentMethod === 'mercadopago'} onChange={() => setPaymentMethod('mercadopago')} className="hidden" />
|
||||
<div className="bg-blue-100 p-2 rounded-full text-blue-600"><Wallet size={24} /></div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">Mercado Pago</div>
|
||||
<div className="text-xs text-gray-500">QR, Débito, Crédito</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${paymentMethod === 'stripe' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 bg-white hover:border-indigo-200'}`}>
|
||||
<input type="radio" name="payment" value="stripe" checked={paymentMethod === 'stripe'} onChange={() => setPaymentMethod('stripe')} className="hidden" />
|
||||
<div className="bg-indigo-100 p-2 rounded-full text-indigo-600"><CreditCard size={24} /></div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">Tarjeta Crédito</div>
|
||||
<div className="text-xs text-gray-500">Procesado por Stripe</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => setStep(4)} className="px-6 py-3 text-slate-600 hover:bg-slate-100 rounded-lg font-medium" disabled={isSubmitting}>Volver</button>
|
||||
<button onClick={handlePublish} disabled={isSubmitting} className="flex-1 py-3 bg-brand-600 text-white font-bold rounded-xl hover:bg-brand-700 disabled:opacity-70 disabled:cursor-not-allowed shadow-lg shadow-brand-200 transition-all text-lg">
|
||||
{isSubmitting ? 'Procesando pago...' : 'Pagar y Publicar'}
|
||||
</button>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL, // Usa la variable de entorno
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Category, Operation } from '../types';
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
selectedCategory: Category | null;
|
||||
selectedOperation: Operation | null;
|
||||
attributes: Record<string, any>;
|
||||
photos: File[]; // Added
|
||||
setStep: (step: number) => void;
|
||||
setCategory: (category: Category) => void;
|
||||
setOperation: (operation: Operation) => void;
|
||||
setAttribute: (key: string, value: any) => void;
|
||||
addPhoto: (file: File) => void; // Added
|
||||
removePhoto: (index: number) => void; // Added
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useWizardStore = create<WizardState>((set) => ({
|
||||
step: 1,
|
||||
selectedCategory: null,
|
||||
selectedOperation: null,
|
||||
attributes: {},
|
||||
photos: [],
|
||||
setStep: (step) => set({ step }),
|
||||
setCategory: (category) => set({ selectedCategory: category, step: 2 }), // Auto advance
|
||||
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }), // Auto advance
|
||||
setAttribute: (key, value) => set((state) => ({
|
||||
attributes: { ...state.attributes, [key]: value }
|
||||
})),
|
||||
addPhoto: (file) => set((state) => ({ photos: [...state.photos, file] })),
|
||||
removePhoto: (index) => set((state) => ({ photos: state.photos.filter((_, i) => i !== index) })),
|
||||
reset: () => set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {}, photos: [] }),
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user