Feat Varios 3

This commit is contained in:
2026-01-06 10:34:06 -03:00
parent 0fa77e4a98
commit 9fa21ebec3
65 changed files with 2897 additions and 373 deletions

View File

@@ -12,6 +12,7 @@ import ModerationPage from './pages/Moderation/ModerationPage';
import ListingExplorer from './pages/Listings/ListingExplorer';
import AuditTimeline from './pages/Audit/AuditTimeline';
import ClientManager from './pages/Clients/ClientManager';
import CouponsPage from './pages/Coupons/CouponsPage';
function App() {
return (
@@ -29,6 +30,7 @@ function App() {
<Route path="/diagram" element={<DiagramPage />} />
<Route path="/pricing" element={<PricingManager />} />
<Route path="/promotions" element={<PromotionsManager />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/reports/categories" element={<SalesByCategory />} />
<Route path="/audit" element={<AuditTimeline />} />
</Route>

View File

@@ -1,4 +1,5 @@
import { X, Printer, Globe, Tag, Image as ImageIcon, Info, User } from 'lucide-react';
import { translateStatus } from '../../utils/translations';
interface ListingDetail {
listing: {
@@ -10,6 +11,8 @@ interface ListingDetail {
adFee: number;
price: number;
status: string;
publicationStartDate?: string;
approvedAt?: string;
printText?: string;
printDaysCount?: number;
isBold?: boolean;
@@ -23,11 +26,13 @@ interface ListingDetail {
export default function ListingDetailModal({
isOpen,
onClose,
detail
detail,
children
}: {
isOpen: boolean;
onClose: () => void;
detail: ListingDetail | null
detail: ListingDetail | null;
children?: React.ReactNode;
}) {
if (!isOpen || !detail) return null;
const { listing, attributes, images } = detail;
@@ -124,8 +129,31 @@ export default function ListingDetailModal({
</div>
<div className="flex justify-between text-sm">
<span className="opacity-60">Estado</span>
<span className="font-bold uppercase text-xs tracking-widest">{listing.status}</span>
<span className="font-bold uppercase text-[10px] text-right leading-tight">
{(() => {
if (listing.status === 'Published' && listing.publicationStartDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const start = new Date(listing.publicationStartDate);
start.setHours(0, 0, 0, 0);
if (start > today) return "APROBADO";
}
return translateStatus(listing.status);
})()}
</span>
</div>
{listing.publicationStartDate && (
<div className="flex justify-between text-sm">
<span className="opacity-60">Fecha Inicio</span>
<span className="font-bold text-xs">{new Date(listing.publicationStartDate).toLocaleDateString()}</span>
</div>
)}
{listing.approvedAt && (
<div className="flex justify-between text-sm">
<span className="opacity-60">Aprobación</span>
<span className="font-bold text-xs">{new Date(listing.approvedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
@@ -162,6 +190,13 @@ export default function ListingDetailModal({
</h3>
<p className="text-sm text-gray-600 italic">"{listing.description || 'Sin descripción adicional.'}"</p>
</div>
{/* ACCIONES EXTRA (EJ: MODERACIÓN) */}
{children && (
<div className="pt-4 border-t border-gray-100">
{children}
</div>
)}
</div>
</div>

View File

@@ -5,21 +5,22 @@ interface ModalProps {
onClose: () => void;
title: string;
children: React.ReactNode;
maxWidth?: string;
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md animate-fade-in">
<div className={`bg-white rounded-lg shadow-xl w-full ${maxWidth} max-h-[90vh] flex flex-col animate-fade-in`}>
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-red-500">
<X size={20} />
</button>
</div>
<div className="p-4">
<div className="p-4 overflow-y-auto">
{children}
</div>
</div>

View File

@@ -1,9 +1,11 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
LogOut, LayoutDashboard, FolderTree, Users,
FileText, DollarSign, Eye, History, User as ClientIcon, Search
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
} from 'lucide-react';
import { useState, useEffect } from 'react';
import api from '../services/api';
export default function ProtectedLayout() {
const { isAuthenticated, role, logout } = useAuthStore();
@@ -11,15 +13,43 @@ export default function ProtectedLayout() {
if (!isAuthenticated) return <Navigate to="/login" replace />;
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
if (isAuthenticated && (role === 'Admin' || role === 'Moderador')) {
const loadCount = async () => {
try {
const res = await api.get('/moderationstats/unread-count');
setUnreadCount(res.data);
} catch { /* silence */ }
};
const handleUpdate = (e: any) => {
if (e.detail !== undefined) setUnreadCount(e.detail);
else loadCount();
};
loadCount();
window.addEventListener('moderation-unread-changed', handleUpdate);
const interval = setInterval(loadCount, 60000);
return () => {
window.removeEventListener('moderation-unread-changed', handleUpdate);
clearInterval(interval);
};
}
}, [isAuthenticated, role]);
// Definición de permisos por ruta
const menuItems = [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
{ label: 'Promociones', href: '/promotions', icon: <Tag size={20} />, roles: ['Admin'] },
{ label: 'Cupones', href: '/coupons', icon: <Tag size={20} />, roles: ['Admin'] },
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] },
];
@@ -37,17 +67,24 @@ export default function ProtectedLayout() {
if (!item.roles.includes(role || '')) return null;
return (
<a
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 p-3 rounded-xl transition-all ${location.pathname === item.href
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
to={item.href}
className={`flex items-center justify-between p-3 rounded-xl transition-all ${location.pathname === item.href
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
{item.icon}
<span className="font-medium text-sm">{item.label}</span>
</a>
<div className="flex items-center gap-3">
{item.icon}
<span className="font-medium text-sm">{item.label}</span>
</div>
{(item as any).badge > 0 && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black ${location.pathname === item.href ? 'bg-white text-blue-600' : 'bg-rose-500 text-white'}`}>
{(item as any).badge}
</span>
)}
</Link>
);
})}
</nav>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import api from '../../services/api';
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Clock } from 'lucide-react';
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Search, Calendar, Users, AlertCircle, Info } from 'lucide-react';
import clsx from 'clsx';
interface AuditLog {
id: number;
@@ -8,58 +9,238 @@ interface AuditLog {
username: string;
createdAt: string;
details: string;
userId: number;
}
export default function AuditTimeline() {
const todayStr = new Date().toISOString().split('T')[0];
const [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<{ id: number, username: string }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/reports/audit').then(res => {
setLogs(res.data);
setLoading(false);
});
}, []);
// ESTADO DE FILTROS
const [filters, setFilters] = useState({
from: todayStr,
to: todayStr,
userId: ""
});
const getIcon = (action: string) => {
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={18} />;
if (action === 'Rechazar') return <XCircle className="text-red-500" size={18} />;
return <FileText className="text-blue-500" size={18} />;
const loadInitialData = async () => {
try {
const res = await api.get('/users');
setUsers(res.data);
} catch (e) { console.error(e); }
};
const loadLogs = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/reports/audit', {
params: {
from: filters.from,
to: filters.to,
userId: filters.userId || null
}
});
setLogs(res.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { loadInitialData(); }, []);
useEffect(() => { loadLogs(); }, [loadLogs]);
const translateDetails = (details: string) => {
if (!details) return details;
return details
.replace(/Published/g, 'Publicado')
.replace(/Pending/g, 'Pendiente')
.replace(/Rejected/g, 'Rechazado')
.replace(/Draft/g, 'Borrador')
.replace(/True/g, 'Sí')
.replace(/False/g, 'No')
.replace(/Total:/g, 'Total:')
.replace(/Featured:/g, 'Destacado:')
.replace(/El usuario (\d+) cambió el estado del aviso #(\d+) a/g, 'Se cambió el estado del aviso #$2 a')
.replace(/Aviso creado por usuario autenticado/g, 'Nuevo aviso creado');
};
const getLogMeta = (action: string) => {
switch (action) {
case 'APPROVE_LISTING':
case 'Published':
case 'Aprobar':
return { icon: <CheckCircle className="text-emerald-500" size={16} />, color: 'bg-emerald-50 text-emerald-700 border-emerald-100', label: 'Aprobación' };
case 'REJECT_LISTING':
case 'Rejected':
case 'Rechazar':
return { icon: <XCircle className="text-rose-500" size={16} />, color: 'bg-rose-50 text-rose-700 border-rose-100', label: 'Rechazo' };
case 'CREATE_LISTING':
case 'CREATE_USER':
case 'CREATE_COUPON':
case 'CREATE_CATEGORY':
return { icon: <FileText className="text-blue-500" size={16} />, color: 'bg-blue-50 text-blue-700 border-blue-100', label: 'Creación' };
case 'UPDATE_USER':
case 'UPDATE_CLIENT':
case 'UPDATE_CATEGORY':
case 'SAVE_PRICING':
case 'UPDATE_PROMOTION':
return { icon: <Search className="text-amber-500" size={16} />, color: 'bg-amber-50 text-amber-700 border-amber-100', label: 'Actualización' };
case 'DELETE_USER':
case 'DELETE_COUPON':
case 'DELETE_CATEGORY':
case 'DELETE_PROMOTION':
return { icon: <XCircle className="text-slate-600" size={16} />, color: 'bg-slate-100 text-slate-700 border-slate-200', label: 'Eliminación' };
case 'LOGIN':
return { icon: <Users className="text-indigo-500" size={16} />, color: 'bg-indigo-50 text-indigo-700 border-indigo-100', label: 'Acceso' };
case 'CONFIRM_PAYMENT':
return { icon: <CheckCircle className="text-cyan-500" size={16} />, color: 'bg-cyan-50 text-cyan-700 border-cyan-100', label: 'Pago' };
default:
return { icon: <Info className="text-slate-400" size={16} />, color: 'bg-slate-50 text-slate-500 border-slate-200', label: action.replace('_', ' ') };
}
};
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-2">
<History className="text-blue-600" />
<h2 className="text-2xl font-bold text-gray-800">Auditoría de Actividad</h2>
<div className="space-y-6 max-w-6xl mx-auto">
{/* HEADER PREMIUM */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
<h2 className="text-3xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
<History size={32} className="text-blue-600" /> Auditoría de Sistema
</h2>
<p className="text-slate-500 font-medium text-sm mt-1">Trazabilidad completa de acciones y cambios de estado</p>
</div>
</div>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="p-4 bg-gray-50 border-b font-bold text-gray-600 text-sm">
Últimas acciones realizadas por el equipo
{/* BARRA DE FILTROS */}
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-xl shadow-slate-200/50 flex flex-wrap items-end gap-6">
<div className="flex-1 min-w-[200px]">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<Users size={12} /> Usuario Responsable
</label>
<select
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2.5 font-bold text-sm outline-none focus:border-blue-500 transition-all appearance-none"
value={filters.userId}
onChange={e => setFilters({ ...filters, userId: e.target.value })}
>
<option value="">TODOS LOS USUARIOS</option>
{users.map(u => <option key={u.id} value={u.id}>{u.username.toUpperCase()}</option>)}
</select>
</div>
<div className="divide-y">
<div className="flex flex-col md:flex-row gap-4 flex-[2]">
<div className="flex-1">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<Calendar size={12} /> Desde
</label>
<input
type="date"
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
value={filters.from}
onChange={e => setFilters({ ...filters, from: e.target.value })}
/>
</div>
<div className="flex-1">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<Calendar size={12} /> Hasta
</label>
<input
type="date"
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
value={filters.to}
onChange={e => setFilters({ ...filters, to: e.target.value })}
/>
</div>
</div>
<button
onClick={loadLogs}
className="bg-slate-900 text-white p-3 rounded-xl hover:bg-black transition-all shadow-lg shadow-slate-200"
>
<Search size={20} />
</button>
</div>
{/* LISTA DE EVENTOS TIPO TIMELINE */}
<div className="relative">
{/* Línea vertical de fondo para el timeline */}
<div className="absolute left-12 top-0 bottom-0 w-0.5 bg-slate-100 hidden md:block"></div>
<div className="space-y-4">
{loading ? (
<div className="p-10 text-center text-gray-400 italic">Cargando historial...</div>
) : logs.map(log => (
<div key={log.id} className="p-4 hover:bg-gray-50 transition flex items-start gap-4">
<div className="mt-1">{getIcon(log.action)}</div>
<div className="flex-1">
<div className="flex justify-between">
<span className="font-bold text-gray-800 flex items-center gap-1 italic">
<UserIcon size={14} className="text-gray-400" /> {log.username}
</span>
<span className="text-xs text-gray-400 flex items-center gap-1">
<Clock size={12} /> {new Date(log.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.details}</p>
</div>
<div className="py-20 text-center flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-slate-100 border-t-blue-600 rounded-full animate-spin"></div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Sincronizando logs...</p>
</div>
))}
) : logs.length === 0 ? (
<div className="bg-slate-50 rounded-[2rem] border border-dashed border-slate-200 p-20 text-center text-slate-400">
<AlertCircle size={40} className="mx-auto mb-4 opacity-20" />
<p className="font-bold uppercase text-xs tracking-widest">No hay eventos para el período seleccionado</p>
</div>
) : logs.map((log, idx) => {
const meta = getLogMeta(log.action);
return (
<div key={log.id} className="relative group animate-fade-in" style={{ animationDelay: `${idx * 50}ms` }}>
<div className="flex flex-col md:flex-row items-start md:items-center gap-6">
{/* HORA Y PUNTO (Timeline) */}
<div className="hidden md:flex flex-col items-end w-24 flex-shrink-0">
<span className="text-xs font-black text-slate-900">{new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{new Date(log.createdAt).toLocaleDateString()}</span>
</div>
{/* ICONO CENTRAL BUBBLE */}
<div className={clsx(
"w-10 h-10 rounded-full flex items-center justify-center border-4 border-white shadow-sm z-10 transition-transform group-hover:scale-110",
meta.color.split(' ')[0]
)}>
{meta.icon}
</div>
{/* CARD DE CONTENIDO */}
<div className="flex-1 bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm group-hover:shadow-md group-hover:border-blue-100 transition-all">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-3">
<div className="flex flex-col">
<span className="text-[10px] font-black text-blue-600 uppercase tracking-widest mb-0.5">{meta.label}</span>
<span className="text-sm font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<UserIcon size={14} className="text-slate-300" /> {log.username}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 bg-slate-50 rounded-full text-[9px] font-black text-slate-400 uppercase tracking-wider border border-slate-100">
#{log.id}
</span>
<div className="md:hidden text-[10px] font-bold text-slate-400">
{new Date(log.createdAt).toLocaleString()}
</div>
</div>
</div>
<p className="text-sm font-medium text-slate-600 leading-relaxed bg-slate-50/50 p-3 rounded-xl border border-slate-50 italic">
{translateDetails(log.details)}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div >
);
}

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
import { Trash2, Plus, Tag } from 'lucide-react';
import { formatDate } from '../../utils/formatters';
interface Coupon {
id: number;
code: string;
discountType: 'Percentage' | 'Fixed';
discountValue: number;
expiryDate?: string;
usageCount: number;
maxUsages?: number;
isActive: boolean;
}
export default function CouponsPage() {
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
// Form State
const [newCoupon, setNewCoupon] = useState({
code: '',
discountType: 'Percentage',
discountValue: 0,
expiryDate: '',
maxUsages: '',
maxUsagesPerUser: ''
});
useEffect(() => {
loadCoupons();
}, []);
const loadCoupons = async () => {
try {
const res = await api.get('/coupons');
setCoupons(res.data);
} catch (error) {
console.error("Error loading coupons", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar cupón?')) return;
try {
await api.delete(`/coupons/${id}`);
loadCoupons();
} catch (error) {
alert("Error al eliminar");
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const payload = {
...newCoupon,
maxUsages: newCoupon.maxUsages ? parseInt(newCoupon.maxUsages) : null,
maxUsagesPerUser: newCoupon.maxUsagesPerUser ? parseInt(newCoupon.maxUsagesPerUser) : null,
expiryDate: newCoupon.expiryDate ? newCoupon.expiryDate : null
};
await api.post('/coupons', payload);
setShowForm(false);
setNewCoupon({ code: '', discountType: 'Percentage', discountValue: 0, expiryDate: '', maxUsages: '', maxUsagesPerUser: '' });
loadCoupons();
} catch (error) {
alert("Error al crear cupón");
}
};
if (loading) return <div className="p-10 text-center">Cargando...</div>;
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-slate-800">Gestión de Cupones</h1>
<button
onClick={() => setShowForm(!showForm)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
>
<Plus size={18} /> Nuevo Cupón
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-white p-6 rounded-xl shadow border border-slate-100 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 items-end">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Código</label>
<input
required
type="text"
className="w-full border p-2 rounded"
placeholder="Ej: CUPON2026"
value={newCoupon.code}
onChange={e => setNewCoupon({ ...newCoupon, code: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Tipo Descuento</label>
<select
className="w-full border p-2 rounded"
value={newCoupon.discountType}
onChange={e => setNewCoupon({ ...newCoupon, discountType: e.target.value })}
>
<option value="Percentage">Porcentaje (%)</option>
<option value="Fixed">Monto Fijo ($)</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Valor</label>
<input
required
type="number"
className="w-full border p-2 rounded"
value={newCoupon.discountValue}
onChange={e => setNewCoupon({ ...newCoupon, discountValue: Number(e.target.value) })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Expiración (Opcional)</label>
<input
type="date"
className="w-full border p-2 rounded"
value={newCoupon.expiryDate}
onChange={e => setNewCoupon({ ...newCoupon, expiryDate: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Usos Máx.</label>
<input
type="number"
className="w-full border p-2 rounded"
placeholder="Ilimitado"
value={newCoupon.maxUsages}
onChange={e => setNewCoupon({ ...newCoupon, maxUsages: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Límite x Usuario</label>
<input
type="number"
className="w-full border p-2 rounded"
placeholder="Ilimitado"
value={newCoupon.maxUsagesPerUser}
onChange={e => setNewCoupon({ ...newCoupon, maxUsagesPerUser: e.target.value })}
/>
</div>
<button type="submit" className="bg-emerald-500 text-white p-2 rounded font-bold hover:bg-emerald-600">
Guardar
</button>
</form>
)}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Código</th>
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Descuento</th>
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Usos</th>
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Expira</th>
<th className="p-4 font-bold text-slate-600 text-xs uppercase text-right">Acciones</th>
</tr>
</thead>
<tbody>
{coupons.map(coupon => (
<tr key={coupon.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50">
<td className="p-4 font-mono font-bold text-slate-800 flex items-center gap-2">
<Tag size={14} className="text-blue-500" />
{coupon.code}
</td>
<td className="p-4 text-sm text-slate-600">
{coupon.discountType === 'Percentage' ? `${coupon.discountValue}%` : `$${coupon.discountValue}`}
</td>
<td className="p-4 text-sm text-slate-600">
{coupon.usageCount} {coupon.maxUsages ? `/ ${coupon.maxUsages}` : ''}
</td>
<td className="p-4 text-sm text-slate-600">
{coupon.expiryDate ? formatDate(coupon.expiryDate) : '-'}
</td>
<td className="p-4 text-right">
<button
onClick={() => handleDelete(coupon.id)}
className="text-rose-400 hover:text-rose-600 transition"
>
<Trash2 size={18} />
</button>
</td>
</tr>
))}
{coupons.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-400 text-sm">No hay cupones activos.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
Tooltip, ResponsiveContainer,
} from 'recharts';
import { dashboardService, type DashboardData } from '../services/dashboardService';
import { translateStatus } from '../utils/translations';
import { useAuthStore } from '../store/authStore';
import api from '../services/api';
import { reportService } from '../services/reportService';
@@ -330,7 +331,7 @@ export default function Dashboard() {
<td className="p-4">
<span className={`flex items-center gap-1.5 font-bold ${t.status === 'Published' ? 'text-green-600' : 'text-orange-500'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${t.status === 'Published' ? 'bg-green-600' : 'bg-orange-500'}`}></div>
{t.status}
{translateStatus(t.status)}
</span>
</td>
</tr>

View File

@@ -9,6 +9,7 @@ import {
X, Monitor, Globe, Download
} from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
import { translateStatus } from '../../utils/translations';
import clsx from 'clsx';
export default function ListingExplorer() {
@@ -19,14 +20,15 @@ export default function ListingExplorer() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDetail, setSelectedDetail] = useState<ListingDetail | null>(null);
const todayStr = new Date().toISOString().split('T')[0];
// ESTADO DE FILTROS
const [filters, setFilters] = useState({
query: searchParams.get('q') || "",
categoryId: "",
origin: "All",
status: "",
from: "",
to: ""
from: todayStr,
to: todayStr
});
const loadInitialData = async () => {
@@ -35,6 +37,7 @@ export default function ListingExplorer() {
};
const loadListings = useCallback(async () => {
if (!filters.from || !filters.to) return;
setLoading(true);
try {
const res = await api.post('/listings/search', {
@@ -42,8 +45,8 @@ export default function ListingExplorer() {
categoryId: filters.categoryId ? parseInt(filters.categoryId) : null,
origin: filters.origin,
status: filters.status,
from: filters.from || null,
to: filters.to || null
from: filters.from,
to: filters.to
});
setListings(res.data);
} finally {
@@ -61,7 +64,7 @@ export default function ListingExplorer() {
};
const clearFilters = () => {
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: "", to: "" });
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: todayStr, to: todayStr });
setSearchParams({});
};
@@ -231,14 +234,63 @@ export default function ListingExplorer() {
</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>
{(() => {
const now = new Date();
const startDate = l.publicationStartDate ? new Date(l.publicationStartDate) : (l.approvedAt ? new Date(l.approvedAt) : null);
const days = l.printDaysCount || 0;
let endDate = null;
if (startDate && days > 0) {
endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + days);
}
if (l.status === 'Pending') {
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-amber-50 text-amber-700 border-amber-200">
Pendiente
</span>
);
}
if (l.status === 'Rejected') {
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-rose-50 text-rose-700 border-rose-200">
Rechazado
</span>
);
}
if (l.status === 'Published') {
if (endDate && now > endDate) {
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-100 text-slate-500 border-slate-300">
Finalizado
</span>
);
}
if (startDate && now < startDate) {
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-blue-50 text-blue-700 border-blue-200">
Aprobado
</span>
);
}
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-emerald-50 text-emerald-700 border-emerald-200">
Publicado
</span>
);
}
return (
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-50 text-slate-500 border-slate-200">
{translateStatus(l.status)}
</span>
);
})()}
</td>
<td className="p-5 text-right">
<button

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { Check, X, Printer, Globe, MessageSquare } from 'lucide-react';
import { Check, X, Printer, Globe, Eye, MessageSquare } from 'lucide-react';
import Modal from '../../components/Modal';
import ListingDetailModal from '../../components/Listings/ListingDetailModal';
interface Listing {
id: number;
@@ -14,18 +16,41 @@ interface Listing {
isBold: boolean;
isFrame: boolean;
printDaysCount: number;
unreadNotesCount: number;
categoryName?: string;
parentCategoryName?: string;
}
export default function ModerationPage() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadPending(); }, []);
// Estados para los Modales
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedListingDetail, setSelectedListingDetail] = useState<any>(null);
const [noteModalOpen, setNoteModalOpen] = useState(false);
const [noteText, setNoteText] = useState('');
const [actionListingId, setActionListingId] = useState<number | null>(null);
const [actionType, setActionType] = useState<'Published' | 'Rejected' | null>(null);
const [threadNotes, setThreadNotes] = useState<any[]>([]);
useEffect(() => {
loadPending();
loadUnreadCount();
}, []);
const loadUnreadCount = async () => {
try {
const res = await api.get('/moderationstats/unread-count');
// Notificar al sidebar
window.dispatchEvent(new CustomEvent('moderation-unread-changed', { detail: res.data }));
} catch { /* silence */ }
};
const loadPending = async () => {
setLoading(true);
try {
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
const res = await api.get('/listings/pending');
setListings(res.data);
} catch {
@@ -34,24 +59,84 @@ export default function ModerationPage() {
finally { setLoading(false); }
};
const handleAction = async (id: number, action: 'Published' | 'Rejected') => {
const handleOpenDetail = async (id: number) => {
try {
await api.put(`/listings/${id}/status`, JSON.stringify(action), {
headers: { 'Content-Type': 'application/json' }
});
const res = await api.get(`/listings/${id}`);
setSelectedListingDetail(res.data);
setDetailModalOpen(true);
} catch {
alert("Error al cargar detalles del aviso");
}
};
const handleOpenAction = (id: number, type: 'Published' | 'Rejected') => {
setActionListingId(id);
setActionType(type);
setNoteText('');
if (type === 'Rejected') {
setNoteModalOpen(true);
} else {
confirmAction(id, type);
}
};
const confirmAction = async (id: number, type: 'Published' | 'Rejected', note?: string) => {
try {
const payload = { status: type, note };
await api.put(`/listings/${id}/status`, payload);
setListings(listings.filter(l => l.id !== id));
setNoteModalOpen(false);
setDetailModalOpen(false);
} catch {
alert("Error al procesar el aviso");
}
};
const handleSubmitNote = async () => {
if (!actionListingId) return;
try {
if (actionType) {
confirmAction(actionListingId, actionType, noteText);
} else {
await api.post(`/listings/${actionListingId}/notes`, { message: noteText });
setNoteModalOpen(false);
setNoteText('');
loadUnreadCount();
alert('Nota enviada al usuario correctamente');
}
} catch {
alert('Error al enviar la nota');
}
};
const handleOpenNoteModal = async (id: number, type: 'Published' | 'Rejected' | null) => {
setActionListingId(id);
setActionType(type);
setNoteText('');
setNoteModalOpen(true);
try {
const res = await api.get(`/listings/${id}/notes`);
setThreadNotes(res.data);
loadUnreadCount();
setListings(prev => prev.map(l => l.id === id ? { ...l, unreadNotesCount: 0 } : l));
} catch {
setThreadNotes([]);
}
};
if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;
return (
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-800">Panel de Moderación</h2>
<p className="text-gray-500">Revisión de avisos entrantes para Web y Diario Papel</p>
<div className="mb-6 flex justify-between items-end">
<div>
<h2 className="text-3xl font-black text-gray-900 tracking-tighter uppercase leading-none mb-2">Panel de Moderación</h2>
<p className="text-gray-500 font-medium">Revisión de avisos entrantes para Web y Diario Papel</p>
</div>
</div>
{listings.length === 0 ? (
@@ -61,74 +146,159 @@ export default function ModerationPage() {
<p className="text-gray-500">No hay avisos pendientes de moderación en este momento.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
<div className="grid grid-cols-1 gap-4">
{listings.map(listing => (
<div key={listing.id} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col md:flex-row">
<div key={listing.id} className={`bg-white rounded-xl shadow-sm border ${listing.unreadNotesCount > 0 ? 'border-blue-400 ring-1 ring-blue-100' : 'border-gray-200'} overflow-hidden flex flex-col md:flex-row relative group`}>
{/* Info del Aviso */}
<div className="flex-1 p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded uppercase tracking-wider">
<div className="flex-1 p-4">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded uppercase tracking-wider border border-blue-100 flex items-center gap-1.5">
ID: #{listing.id}
{listing.unreadNotesCount > 0 && (
<span className="flex items-center gap-1 bg-blue-600 text-white px-1.5 py-0.5 rounded-md animate-pulse">
<MessageSquare size={8} fill="white" />
{listing.unreadNotesCount}
</span>
)}
</span>
<h3 className="text-xl font-bold text-gray-900 mt-1">{listing.title}</h3>
<h3 className="text-lg font-black text-gray-900 truncate max-w-md">{listing.title}</h3>
</div>
<div className="text-right">
<p className="text-lg font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
<p className="text-xs text-gray-400">{new Date(listing.createdAt).toLocaleString()}</p>
<p className="text-sm font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Versión Web */}
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
<div className="flex items-center gap-2 mb-2 text-gray-700 font-bold text-sm">
<Globe size={16} /> VERSIÓN WEB / MOBILE
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100 text-[13px] leading-relaxed">
<div className="flex items-center gap-2 mb-1 text-gray-400 font-black text-[9px] uppercase tracking-widest">
<Globe size={12} /> Web / Mobile
</div>
<p className="text-sm text-gray-600 italic line-clamp-3">"{listing.description}"</p>
<p className="text-gray-600 italic line-clamp-2">"{listing.description}"</p>
</div>
{/* Versión Impresa - CRÍTICO */}
<div className={`p-4 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-gray-700 font-bold text-sm">
<Printer size={16} /> VERSIÓN IMPRESA
<div className={`p-3 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white text-[13px]`}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 text-gray-400 font-black text-[9px] uppercase tracking-widest">
<Printer size={12} /> Diario Papel
</div>
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded font-bold">
{listing.printDaysCount} DÍAS
<span className="text-[9px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-black uppercase">
{listing.printDaysCount} días
</span>
</div>
<div className={`text-sm p-2 bg-gray-50 rounded border border-dashed border-gray-300 ${listing.isBold ? 'font-bold' : ''}`}>
{listing.printText || "Sin texto de impresión definido"}
<div className={`p-1 bg-gray-50 rounded border border-dashed border-gray-200 line-clamp-2 ${listing.isBold ? 'font-bold' : ''}`}>
{listing.printText || "Sin texto..."}
</div>
</div>
</div>
</div>
{/* Acciones Laterales */}
<div className="bg-gray-50 border-t md:border-t-0 md:border-l border-gray-200 p-6 flex flex-row md:flex-col gap-3 justify-center min-w-[200px]">
<button
onClick={() => handleAction(listing.id, 'Published')}
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition shadow-sm"
>
<Check size={20} /> APROBAR
</button>
<button
onClick={() => handleAction(listing.id, 'Rejected')}
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<X size={20} /> RECHAZAR
</button>
<button className="p-3 text-gray-400 hover:text-gray-600 flex items-center justify-center gap-2 text-xs font-medium">
<MessageSquare size={16} /> Enviar Nota
</button>
</div>
<div className="bg-gray-50/50 border-t md:border-t-0 md:border-l border-gray-200 p-4 flex flex-row md:flex-col gap-2 justify-center min-w-[180px]">
<div className="flex gap-2 md:flex-col w-full">
<button
onClick={() => handleOpenAction(listing.id, 'Published')}
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition shadow-sm shadow-green-100"
>
<Check size={18} /> Aprobar
</button>
<button
onClick={() => handleOpenAction(listing.id, 'Rejected')}
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition"
>
<X size={18} /> Rechazar
</button>
</div>
<div className="flex gap-2 w-full mt-1">
<button
onClick={() => handleOpenNoteModal(listing.id, null)}
className={`flex-1 py-2 rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition border ${listing.unreadNotesCount > 0
? 'bg-blue-600 text-white border-blue-700 shadow-md shadow-blue-100'
: 'text-slate-600 hover:bg-slate-100 border-transparent'}`}
>
<MessageSquare size={14} fill={listing.unreadNotesCount > 0 ? "white" : "none"} />
{listing.unreadNotesCount > 0 ? 'Responder' : 'Nota'}
</button>
<button
onClick={() => handleOpenDetail(listing.id)}
className="flex-1 py-2 text-blue-600 hover:bg-blue-50 border border-transparent rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition"
>
<Eye size={14} /> Detalle
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal de Detalle Premium Reutilizado */}
<ListingDetailModal
isOpen={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
detail={selectedListingDetail}
>
<div className="flex flex-col gap-3 pt-2">
<button
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Published')}
className="w-full py-4 bg-green-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-lg shadow-green-100 hover:bg-green-700 transition-all flex items-center justify-center gap-2"
>
<Check size={18} /> Aprobar aviso
</button>
<button
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Rejected')}
className="w-full py-3 bg-white text-red-600 border border-red-100 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-red-50 transition-all flex items-center justify-center gap-2"
>
<X size={16} /> Rechazar aviso
</button>
</div>
</ListingDetailModal>
{/* Modal de Nota / Rechazo */}
<Modal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} title={actionType === 'Rejected' ? 'Rechazar Aviso' : actionType === 'Published' ? 'Aprobar con Nota' : 'Chat con el Usuario'}>
<div className="space-y-4">
{threadNotes.length > 0 && (
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100 max-h-64 overflow-y-auto space-y-3 mb-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Historial de mensajes</p>
{threadNotes.map((n: any) => (
<div key={n.id} className={`flex flex-col ${n.isFromModerator ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[80%] p-3 rounded-2xl text-sm ${n.isFromModerator ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-700 rounded-tl-none'}`}>
{n.message}
</div>
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">
{n.isFromModerator ? 'Moderador' : 'Usuario'} {new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
)}
<p className="text-sm text-gray-600">
{actionType === 'Rejected'
? 'Por favor indica la razón del rechazo para notificar al usuario.'
: actionType === 'Published'
? 'Opcionalmente, agrega una nota al aprobar este aviso.'
: 'Escribe un mensaje al usuario. Recibirá una notificación en su panel.'}
</p>
<textarea
className="w-full border rounded-2xl p-4 text-sm h-32 focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="Escribe tu mensaje aquí..."
value={noteText}
onChange={e => setNoteText(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2">
<button onClick={() => setNoteModalOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl font-bold transition-all">Cancelar</button>
<button
onClick={handleSubmitNote}
className={`px-6 py-2.5 rounded-xl text-white font-black uppercase text-xs tracking-widest transition-all ${actionType === 'Rejected' ? 'bg-red-600 hover:bg-red-700 shadow-lg shadow-red-200' : 'bg-blue-600 hover:bg-blue-700 shadow-lg shadow-blue-200'}`}
>
{actionType === 'Rejected' ? 'Confirmar Rechazo' : actionType === 'Published' ? 'Aprobar' : 'Enviar Mensaje'}
</button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -187,6 +187,7 @@ export default function UserManager() {
<option value="Admin">Admin</option>
<option value="Cajero">Cajero</option>
<option value="Usuario">Usuario</option>
<option value="Moderador">Moderador</option>
<option value="Diagramador">Diagramador</option>
</select>
</div>

View File

@@ -18,6 +18,9 @@ export interface Listing {
viewCount: number;
origin: string;
overlayStatus?: string | null;
printDaysCount?: number;
publicationStartDate?: string;
approvedAt?: string;
}
export interface ListingAttribute {

View File

@@ -0,0 +1,16 @@
export const formatDate = (dateString: string) => {
if (!dateString) return '-';
const date = new Date(dateString);
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
};
export const formatCurrency = (amount: number, currency: string = 'ARS') => {
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: currency
}).format(amount);
};

View File

@@ -0,0 +1,22 @@
// src/utils/translations.ts
export const translateStatus = (status: string): string => {
const map: Record<string, string> = {
'Published': 'Publicado',
'Pending': 'Pendiente',
'Rejected': 'Rechazado',
'Draft': 'Borrador',
'Drafted': 'Borrador',
'Review': 'En Revisión',
};
return map[status] || status;
};
export const translateOrigin = (origin: string): string => {
const map: Record<string, string> = {
'Web': 'Web',
'Mostrador': 'Mostrador',
'All': 'Todos',
};
return map[origin] || origin;
};

View File

@@ -31,7 +31,7 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
setLoading(true);
try {
// Enviamos el fondo inicial al endpoint que creamos en C#
await api.post('/cashsessions/open', openingBalance);
await api.post('/cashsessions/open', { openingBalance });
showToast("Caja abierta correctamente. ¡Buen turno!", "success");
onSuccess();
} catch (error) {
@@ -47,11 +47,10 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
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) */}
{/* Header */}
<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>
@@ -89,13 +88,6 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
</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"
@@ -109,7 +101,7 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
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'}
<Play size={18} fill="currentColor" /> {loading ? 'Abriendo...' : 'Iniciar Turno'}
</button>
</div>
</form>

View File

@@ -3,7 +3,7 @@ import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom';
import {
LayoutDashboard, PlusCircle, Banknote, LogOut,
ChevronLeft, ChevronRight, Settings, Bell,
Search, User as UserIcon, Monitor,
User as UserIcon, Monitor,
TrendingUp, ClipboardList, ShieldCheck
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -138,10 +138,7 @@ export default function CounterLayout() {
{/* 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>
{/* Buscador eliminado por redundancia */}
</div>
<div className="flex items-center gap-4">

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { usePublicAuthStore } from './store/publicAuthStore';
import { motion, AnimatePresence } from 'framer-motion';
import api from './services/api';
// Páginas e Interfaz
import HomePage from './pages/HomePage';
@@ -43,12 +44,46 @@ const SocialIcons = {
function App() {
const { user, logout } = usePublicAuthStore();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const handleLogout = () => {
logout();
setIsUserMenuOpen(false);
};
// Cargar conteo de mensajes no leídos
useEffect(() => {
if (user) {
const loadUnread = async () => {
try {
const res = await api.get('/usernotes/unread-count');
setUnreadCount(res.data);
} catch (e) {
console.error("Error cargando notificaciones", e);
}
};
const handleUnreadChange = (e: any) => {
if (e.detail !== undefined) {
setUnreadCount(e.detail);
} else {
loadUnread();
}
};
loadUnread();
window.addEventListener('unread-count-changed', handleUnreadChange);
// Polling cada 60 segundos por si acaso
const interval = setInterval(loadUnread, 60000);
return () => {
window.removeEventListener('unread-count-changed', handleUnreadChange);
clearInterval(interval);
};
}
}, [user]);
return (
<BrowserRouter>
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
@@ -68,57 +103,86 @@ function App() {
<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="flex items-center gap-3">
{/* CAMPANA DE NOTIFICACIONES */}
<Link
to="/profile?tab=mensajes"
className="relative p-3 bg-slate-50 rounded-2xl border border-slate-100 text-slate-400 hover:text-blue-600 hover:bg-white transition-all group"
title="Ver mensajes"
>
<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>
</>
<Mail size={20} className="group-hover:scale-110 transition-transform" />
{unreadCount > 0 && (
<span className="absolute top-2 right-2 flex h-5 w-5 items-center justify-center rounded-full bg-rose-500 text-[10px] font-black text-white ring-4 ring-white shadow-lg shadow-rose-200 animate-bounce">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</AnimatePresence>
</Link>
<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>
{/* Acceso a mensajes desde el menú movil o dropdown */}
<Link
to="/profile?tab=mensajes"
onClick={() => setIsUserMenuOpen(false)}
className="flex md:hidden 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">
<Mail size={18} className="group-hover:text-blue-600" />
</div>
<span className="text-xs font-black uppercase tracking-widest">Mensajes</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>
</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">

View File

@@ -46,7 +46,8 @@ export default function HomePage() {
const response = await api.post('/listings/search', {
query: searchText,
categoryId: selectedCatId,
attributes: dynamicFilters
attributes: dynamicFilters,
onlyActive: true
});
setListings(response.data);
} catch (e) {

View File

@@ -121,6 +121,15 @@ export default function ListingDetailPage() {
</span>
</div>
{/* Featured Badge */}
{listing.isFeatured && (
<div className="mb-6 flex">
<span className="bg-amber-400 text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-wider shadow-md shadow-amber-200">
Destacado
</span>
</div>
)}
<h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
{listing.title}
</h1>
@@ -148,13 +157,13 @@ export default function ListingDetailPage() {
</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>
{listing.allowContact !== false && (
<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>
)}
{/* Botón Ofrecer removido por solicitud */}
</div>
{/* Stats integrados */}

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { publicAuthService } from '../services/authService';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } 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
Bell, RefreshCcw, Send, MessageSquare, X
} from 'lucide-react';
import { usePublicAuthStore } from '../store/publicAuthStore';
import api from '../services/api';
@@ -19,7 +19,7 @@ interface MfaData {
secret: string;
}
type TabType = 'listings' | 'security' | 'settings';
type TabType = 'listings' | 'security' | 'settings' | 'mensajes';
export default function ProfilePage() {
const { user, logout } = usePublicAuthStore();
@@ -31,8 +31,88 @@ export default function ProfilePage() {
const baseUrl = import.meta.env.VITE_BASE_URL;
const setRepublishData = useWizardStore(state => state.setRepublishData);
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
const [republishDetail, setRepublishDetail] = useState<any | null>(null);
const [loadingRepublish, setLoadingRepublish] = useState(false);
const navigate = useNavigate();
const [billingData, setBillingData] = useState({
billingName: '',
billingAddress: '',
billingTaxId: '',
billingTaxType: '',
email: ''
});
const [notes, setNotes] = useState<any[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [selectedThreadListingId, setSelectedThreadListingId] = useState<number | null>(null);
const [replyText, setReplyText] = useState('');
const [searchParams] = useSearchParams();
useEffect(() => {
const tab = searchParams.get('tab');
if (tab === 'mensajes') setActiveTab('mensajes');
}, [searchParams]);
useEffect(() => {
if (activeTab === 'settings') {
api.get('/profile').then(res => {
setBillingData({
billingName: res.data.billingName || '',
billingAddress: res.data.billingAddress || '',
billingTaxId: res.data.billingTaxId || '',
billingTaxType: res.data.billingTaxType || '',
email: res.data.email || ''
});
}).catch(console.error);
}
if (activeTab === 'mensajes') {
loadMessages();
}
}, [activeTab]);
const loadMessages = async () => {
try {
const res = await api.get('/usernotes/my-notes');
setNotes(res.data);
// Actualizar conteo de no leídos
const unreadRes = await api.get('/usernotes/unread-count');
setUnreadCount(unreadRes.data);
// Notificar al header (App.tsx)
window.dispatchEvent(new CustomEvent('unread-count-changed', { detail: unreadRes.data }));
} catch (e) { console.error(e); }
};
const handleReply = async (listingId: number) => {
if (!replyText.trim()) return;
try {
await api.post(`/usernotes/listing/${listingId}/reply`, { message: replyText });
setReplyText('');
loadMessages();
} catch {
alert("Error al enviar respuesta");
}
};
const markThreadAsRead = async (listingId: number) => {
// El backend lo hace automáticamente cuando pides el historial por aviso (en una implementación real ideal)
// Pero aquí lo hacemos manual o confiamos en el endpoint GetByListing que marcaba como leídos.
try {
await api.get(`/usernotes/listing/${listingId}`);
loadMessages();
} catch (e) { console.error(e); }
};
const handleSaveProfile = async () => {
try {
await api.put('/profile', billingData);
alert('Ajustes guardados correctamente.');
} catch (error) {
alert('Error al guardar ajustes.');
}
};
useEffect(() => {
if (!user) {
navigate('/login');
@@ -70,6 +150,19 @@ export default function ProfilePage() {
}
};
// Carga de detalle para el modal de republicación
useEffect(() => {
if (republishTarget) {
setLoadingRepublish(true);
api.get(`/listings/${republishTarget.id}`)
.then(res => setRepublishDetail(res.data))
.catch(() => alert("Error al cargar detalles del aviso"))
.finally(() => setLoadingRepublish(false));
} else {
setRepublishDetail(null);
}
}, [republishTarget]);
const handleSetupMfa = async () => {
try {
const data = await publicAuthService.setupMfa();
@@ -190,6 +283,13 @@ export default function ProfilePage() {
active={activeTab === 'security'}
onClick={() => setActiveTab('security')}
/>
<SidebarItem
icon={<Bell size={18} />}
label="Mensajes"
active={activeTab === 'mensajes'}
onClick={() => setActiveTab('mensajes')}
badge={unreadCount}
/>
<SidebarItem
icon={<Settings size={18} />}
label="Ajustes"
@@ -250,20 +350,67 @@ export default function ProfilePage() {
<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>
{(() => {
const now = new Date();
const startDate = item.publicationStartDate ? new Date(item.publicationStartDate) : (item.approvedAt ? new Date(item.approvedAt) : null);
const days = item.printDaysCount || 0;
// Calcular fecha fin: Inicio + días
let endDate = null;
if (startDate && days > 0) {
endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + days);
}
if (item.status === 'Pending') {
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-amber-500 bg-amber-50 border-amber-100">
En Revisión
</span>
);
}
if (item.status === 'Rejected') {
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-rose-500 bg-rose-50 border-rose-100">
Rechazado
</span>
);
}
if (item.status === 'Published') {
// Si ya terminó el plazo
if (endDate && now > endDate) {
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
Finalizado
</span>
);
}
// Si está aprobado pero falta para que empiece
if (startDate && now < startDate) {
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-blue-500 bg-blue-50 border-blue-100">
Aprobado
</span>
);
}
// Por defecto: Publicado (Activo)
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-emerald-500 bg-emerald-50 border-emerald-100">
Publicado
</span>
);
}
return (
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
Borrador
</span>
);
})()}
</div>
</div>
</div>
@@ -380,61 +527,361 @@ export default function ProfilePage() {
{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 className="bg-blue-50/50 p-6 rounded-3xl mb-8 border border-blue-100">
<h3 className="text-sm font-black text-blue-900 uppercase mb-4">Datos de Contacto</h3>
<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={billingData.email}
onChange={(e: any) => setBillingData({ ...billingData, email: e.target.value })}
/>
</div>
</div>
<div className="bg-slate-50 p-6 rounded-3xl mb-8 border border-slate-100">
<h3 className="text-sm font-black text-slate-900 uppercase mb-4 flex gap-2 items-center">
<Settings size={16} className="text-slate-400" /> Datos de Facturación
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup
label="Razón Social / Nombre"
placeholder="Ej: Juan Perez"
value={billingData.billingName}
onChange={(e: any) => setBillingData({ ...billingData, billingName: e.target.value })}
/>
<InputGroup
label="CUIT / DNI"
placeholder="20-12345678-9"
value={billingData.billingTaxId}
onChange={(e: any) => setBillingData({ ...billingData, billingTaxId: e.target.value })}
/>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Condición Fiscal</label>
<select
value={billingData.billingTaxType}
onChange={(e) => setBillingData({ ...billingData, billingTaxType: e.target.value })}
className="w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-700 focus:ring-4 focus:ring-blue-100 transition-all outline-none appearance-none"
>
<option value="">Seleccionar...</option>
<option value="Consumidor Final">Consumidor Final</option>
<option value="Responsable Inscripto">Responsable Inscripto</option>
<option value="Monotributista">Monotributista</option>
<option value="Exento">Exento</option>
</select>
</div>
<InputGroup
label="Domicilio Fiscal"
placeholder="Calle 123, Ciudad"
value={billingData.billingAddress}
onChange={(e: any) => setBillingData({ ...billingData, billingAddress: e.target.value })}
/>
</div>
</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">
<button
onClick={handleSaveProfile}
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>
)}
{activeTab === 'mensajes' && (
<motion.div key="mensajes" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">Mensajes de Moderación</h2>
{unreadCount > 0 && (
<span className="bg-rose-500 text-white px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest animate-pulse">
{unreadCount} Nuevos
</span>
)}
</div>
{notes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 opacity-30">
<MessageSquare size={64} className="mb-4" />
<p className="text-slate-400 text-sm font-bold uppercase tracking-widest">No tienes mensajes</p>
</div>
) : (
<div className="space-y-6">
{/* Agrupar mensajes por ListingId preliminarmente */}
{Array.from(new Set(notes.map(n => n.listingId))).map(listingId => {
const thread = notes.filter(n => n.listingId === listingId).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const hasUnread = thread.some(n => !n.isRead && n.isFromModerator);
const isExpanded = selectedThreadListingId === listingId;
return (
<div key={listingId} className={clsx(
"rounded-[2rem] border transition-all overflow-hidden",
hasUnread ? "border-blue-200 bg-blue-50/30" : "border-slate-100 bg-slate-50/50"
)}>
{/* Cabecera del Hilo */}
<button
onClick={() => {
setSelectedThreadListingId(isExpanded ? null : listingId);
if (!isExpanded) markThreadAsRead(listingId);
}}
className="w-full p-6 flex items-center justify-between hover:bg-white/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={clsx(
"w-12 h-12 rounded-2xl flex items-center justify-center transition-colors",
hasUnread ? "bg-blue-600 text-white shadow-lg shadow-blue-100" : "bg-slate-200 text-slate-500"
)}>
<MessageSquare size={20} />
</div>
<div className="text-left">
<h4 className="font-black text-slate-900 uppercase text-xs">Conversación sobre Aviso #{listingId}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
{thread.length} Mensajes Último: {new Date(thread[thread.length - 1].createdAt).toLocaleDateString()}
</p>
</div>
</div>
<ChevronRight className={clsx("text-slate-300 transition-transform", isExpanded && "rotate-90")} />
</button>
{/* Cuerpo del Chat */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className="overflow-hidden bg-white/40 border-t border-slate-100"
>
<div className="p-6 space-y-4">
{thread.map((msg) => (
<div key={msg.id} className={clsx(
"flex flex-col max-w-[85%]",
msg.isFromModerator ? "self-start" : "self-end ml-auto items-end"
)}>
<div className={clsx(
"px-5 py-3 rounded-[1.5rem] shadow-sm text-sm",
msg.isFromModerator
? "bg-white border border-slate-100 text-slate-700 rounded-bl-none"
: "bg-blue-600 text-white rounded-br-none"
)}>
{msg.message}
</div>
<div className="flex items-center gap-2 mt-1 px-2">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">
{msg.isFromModerator ? 'Soporte SIG-CM' : 'Tú'} {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{!msg.isRead && msg.isFromModerator && (
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full"></span>
)}
</div>
</div>
))}
{/* Caja de Respuesta */}
<div className="mt-8 pt-6 border-t border-slate-100">
<div className="relative">
<textarea
placeholder="Escribe una respuesta al moderador..."
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
className="w-full bg-slate-50 border-none rounded-2xl p-4 pr-14 text-sm focus:ring-2 focus:ring-blue-100 outline-none transition-all resize-none h-24 font-medium"
/>
<button
onClick={() => handleReply(listingId)}
disabled={!replyText.trim()}
className="absolute bottom-4 right-4 p-3 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-200 hover:bg-blue-700 disabled:opacity-50 disabled:shadow-none transition-all"
>
<Send size={18} />
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</main>
</div>
</div >
{/* MODAL DE CONFIRMACIÓN */}
{/* MODAL DE CONFIRMACIÓN DE REPUBLIVACIÓN PREMIUM */}
<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"
className="absolute inset-0 bg-slate-900/80 backdrop-blur-md"
/>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
initial={{ scale: 0.95, 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"
exit={{ scale: 0.95, opacity: 0, y: 20 }}
className="relative bg-white rounded-[3rem] shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col 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">
{/* HEADER TIPO PANEL */}
<div className="p-8 border-b border-slate-50 flex justify-between items-start bg-slate-50/50">
<div>
<div className="flex items-center gap-2 text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-2">
<RefreshCcw size={12} className="animate-spin-slow" /> Republicar aviso
</div>
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">
{republishTarget.title}
</h3>
</div>
<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"
className="p-3 bg-white text-slate-400 hover:text-slate-900 rounded-2xl shadow-sm border border-slate-100 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
<X size={20} />
</button>
</div>
{/* CONTENIDO SCROLLABLE */}
<div className="flex-1 overflow-y-auto p-8">
{loadingRepublish ? (
<div className="py-20 text-center flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-100 border-t-blue-600 rounded-full animate-spin"></div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Recuperando ficha técnica...</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
{/* IZQUIERDA: Ficha y Galería */}
<div className="lg:col-span-7 space-y-10">
{/* PREVIEW IMAGE GRID */}
{republishDetail?.images?.length > 0 && (
<div className="grid grid-cols-2 gap-4">
{republishDetail.images.slice(0, 2).map((img: any) => (
<div key={img.id} className="aspect-video rounded-3xl overflow-hidden border border-slate-100 shadow-sm">
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" />
</div>
))}
</div>
)}
{/* FICHA TÉCNICA MINI */}
<div>
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
<Package size={14} /> Ficha Técnica a clonar
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{republishDetail?.attributes?.map((attr: any) => (
<div key={attr.id} className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
<span className="block text-[8px] font-black text-slate-400 uppercase mb-1">{attr.attributeName}</span>
<span className="text-xs font-black text-slate-700 uppercase">{attr.value}</span>
</div>
))}
</div>
</div>
{/* TEXTO IMPRESO */}
{republishDetail?.listing?.printText && (
<div className="p-6 bg-blue-50/30 rounded-3xl border border-blue-50">
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
<Clock size={14} /> Texto de Impresión
</h4>
<p className="text-sm font-medium text-blue-900 italic leading-relaxed">
"{republishDetail.listing.printText}"
</p>
</div>
)}
</div>
{/* DERECHA: Advertencias y CTA */}
<div className="lg:col-span-5 space-y-6">
{/* CARD DE PRECIO ACTUAL */}
<div className="p-8 bg-slate-900 rounded-[2.5rem] shadow-2xl text-white relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 opacity-10">
<RefreshCcw size={80} />
</div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 relative z-10">Último precio</p>
<div className="text-4xl font-black text-emerald-400 tracking-tighter relative z-10 transition-all">
${republishTarget.price.toLocaleString()}
</div>
<div className="mt-6 pt-6 border-t border-white/10 flex flex-col gap-2 relative z-10">
<div className="flex justify-between items-center">
<span className="text-[10px] font-black text-white/40 uppercase">Vistas totales</span>
<span className="text-xs font-black flex items-center gap-1"><Eye size={12} /> {republishTarget.viewCount}</span>
</div>
{republishTarget.publicationStartDate && (
<div className="flex justify-between items-center">
<span className="text-[10px] font-black text-white/40 uppercase">Publicado el</span>
<span className="text-[10px] font-black">{new Date(republishTarget.publicationStartDate).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
{/* ADVERTENCIA DE VIGENCIA */}
{(() => {
const now = new Date();
const startDate = republishTarget.publicationStartDate ? new Date(republishTarget.publicationStartDate) : (republishTarget.approvedAt ? new Date(republishTarget.approvedAt) : null);
const days = republishTarget.printDaysCount || 0;
let endDate = null;
if (startDate && days > 0) {
endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + days);
}
const remainsActive = republishTarget.status === 'Published' && (endDate ? now <= endDate : true);
if (remainsActive) {
const dateStr = endDate?.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric' }) || 'N/A';
return (
<div className="p-6 bg-amber-50 border border-amber-100 rounded-3xl">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-amber-500 text-white rounded-xl shadow-lg shadow-amber-200">
<ShieldCheck size={16} />
</div>
<span className="text-[10px] font-black text-amber-900 uppercase">Aviso Vigente</span>
</div>
<p className="text-xs text-amber-800 font-medium leading-relaxed">
Este aviso caduca el <strong>{dateStr}</strong>. Si lo republicas generará un nuevo cargo duplicado.
</p>
</div>
);
}
return null;
})()}
{/* INFO DE CLONACIÓN */}
<div className="p-6 bg-slate-50 rounded-3xl border border-slate-100">
<p className="text-[10px] text-slate-500 font-bold leading-relaxed">
Al confirmar, se abrirá el wizard con todos los datos cargados. Podrás ajustar precio, fotos y texto antes de pagar.
</p>
</div>
{/* ACCIONES */}
<div className="flex flex-col gap-3 pt-4">
<button
onClick={handleConfirmRepublish}
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-3xl shadow-2xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
>
Confirmar y Editar <ChevronRight size={18} />
</button>
<button
onClick={() => setRepublishTarget(null)}
className="w-full py-4 text-slate-400 font-black uppercase text-[10px] tracking-widest hover:text-slate-900 transition-colors"
>
Cerrar Vista Previa
</button>
</div>
</div>
</div>
)}
</div>
</motion.div>
</div>
)}
@@ -444,30 +891,41 @@ export default function ProfilePage() {
}
// COMPONENTES AUXILIARES PARA LIMPIEZA
function SidebarItem({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
function SidebarItem({ icon, label, active, onClick, badge }: { icon: any, label: string, active: boolean, onClick: () => void, badge?: number }) {
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
className={`w-full flex items-center justify-between 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}
<div className="flex items-center gap-3">
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
{icon}
</span>
{label}
</div>
{badge ? badge > 0 && (
<span className={clsx(
"w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-black",
active ? "bg-white text-blue-600" : "bg-rose-500 text-white shadow-lg shadow-rose-200"
)}>
{badge}
</span>
) : null}
</button>
);
}
function InputGroup({ label, value, disabled, placeholder }: any) {
function InputGroup({ label, value, onChange, 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}
value={value}
onChange={onChange}
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'

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWizardStore } from '../store/wizardStore';
import CategorySelection from './Steps/CategorySelection';
import OperationSelection from './Steps/OperationSelection';
@@ -9,13 +10,24 @@ import SummaryStep from './Steps/SummaryStep';
import { wizardService } from '../services/wizardService';
import type { AttributeDefinition } from '../types';
import SEO from '../components/SEO';
import api from '../services/api';
import { User as UserIcon } from 'lucide-react';
export default function PublishPage() {
const { step, selectedCategory } = useWizardStore();
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
const [profileComplete, setProfileComplete] = useState<boolean | null>(null);
const navigate = useNavigate();
useEffect(() => {
api.get('/profile').then(res => {
const data = res.data;
const isComplete = !!(data.billingName && data.billingAddress && data.billingTaxId && data.billingTaxType);
setProfileComplete(isComplete);
}).catch(() => setProfileComplete(false));
}, []);
useEffect(() => {
// CAMBIO: Agregamos el guard aquí también
if (selectedCategory?.id) {
wizardService.getAttributes(selectedCategory.id)
.then(setDefinitions)
@@ -23,6 +35,34 @@ export default function PublishPage() {
}
}, [selectedCategory?.id]);
if (profileComplete === null) {
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>
);
}
if (profileComplete === false) {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center p-10 text-center animate-in fade-in zoom-in duration-500">
<div className="w-24 h-24 bg-amber-50 text-amber-500 rounded-[2.5rem] flex items-center justify-center mb-8 shadow-xl shadow-amber-100 border border-amber-100">
<UserIcon size={48} />
</div>
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter mb-4">Perfil Incompleto</h2>
<p className="text-slate-500 max-w-sm mb-10 font-medium leading-relaxed">
Para poder publicar un aviso, primero debes completar tus datos de facturación. Esto es obligatorio para la emisión legal de comprobantes.
</p>
<button
onClick={() => navigate('/profile?tab=settings')}
className="bg-slate-900 text-white px-12 py-5 rounded-[2rem] font-black uppercase text-xs tracking-widest shadow-2xl shadow-slate-200 hover:bg-blue-600 transition-all active:scale-95"
>
Completar mis datos ahora
</button>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
<SEO

View File

@@ -61,7 +61,11 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
isBold: false,
isFrame: false,
printFontSize: 'normal',
printAlignment: 'left'
printAlignment: 'left',
publicationStartDate: attributes['publicationStartDate'],
isFeatured: attributes['isFeatured'] === 'true',
allowContact: attributes['allowContact'] === 'true',
couponCode: attributes['couponCode']
};
const result = await wizardService.createListing(payload);

View File

@@ -13,27 +13,35 @@ interface PricingResult {
wordCount: number;
specialCharCount: number;
details: string;
discount: number;
}
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 [startDate, setStartDate] = useState<Date>(new Date());
const [isFeatured, setIsFeatured] = useState<boolean>(false);
const [allowContact, setAllowContact] = useState<boolean>(true);
const [couponCode, setCouponCode] = useState<string>('');
const [pricing, setPricing] = useState<PricingResult>({
totalPrice: 0,
baseCost: 0,
extraCost: 0,
wordCount: 0,
specialCharCount: 0,
details: ''
details: '',
discount: 0
});
const [loadingPrice, setLoadingPrice] = useState(false);
const debouncedText = useDebounce(text, 800);
const debouncedCoupon = useDebounce(couponCode, 800);
useEffect(() => {
if (!selectedCategory || debouncedText.length === 0) {
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '' });
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '', discount: 0 });
return;
}
@@ -44,9 +52,11 @@ export default function TextEditorStep() {
categoryId: selectedCategory.id,
text: debouncedText,
days: days,
isBold: false,
isFrame: false,
startDate: new Date().toISOString()
isBold: false, // Could expose this too
isFrame: false, // Could expose this too
isFeatured: isFeatured,
startDate: startDate.toISOString(),
couponCode: debouncedCoupon
});
setPricing(response.data);
} catch (error) {
@@ -57,7 +67,7 @@ export default function TextEditorStep() {
};
calculatePrice();
}, [debouncedText, days, selectedCategory]);
}, [debouncedText, days, selectedCategory, isFeatured, startDate, debouncedCoupon]);
const handleContinue = () => {
if (text.trim().length === 0) {
@@ -67,6 +77,10 @@ export default function TextEditorStep() {
setAttribute('description', text);
setAttribute('days', days.toString());
setAttribute('adFee', pricing.totalPrice.toString());
setAttribute('publicationStartDate', startDate.toISOString());
setAttribute('allowContact', allowContact.toString());
setAttribute('isFeatured', isFeatured.toString());
setAttribute('couponCode', couponCode);
setStep(5); // Siguiente paso
};
@@ -128,28 +142,88 @@ export default function TextEditorStep() {
</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} />
{/* BLOQUE 2: CONFIGURACIÓN DE PUBLICACIÓN */}
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 space-y-8">
<div className="flex flex-col md:flex-row gap-8 justify-between">
{/* Días */}
<div className="flex items-center gap-6">
<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</h4>
<div className="flex items-center bg-slate-100 rounded-xl p-1 mt-2">
<button onClick={() => setDays(Math.max(1, days - 1))} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">-</button>
<input
type="number"
value={days}
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
className="w-12 text-center bg-transparent border-none outline-none font-black text-lg text-blue-600"
/>
<button onClick={() => setDays(days + 1)} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">+</button>
</div>
</div>
</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>
{/* Fecha de Inicio */}
<div className="flex items-center gap-6">
<div className="p-3 bg-blue-50 rounded-2xl text-blue-600">
<Calendar size={20} />
</div>
<div>
<h4 className="font-black uppercase text-xs text-slate-900">Fecha de Inicio</h4>
<input
type="date"
value={startDate.toISOString().split('T')[0]}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setStartDate(new Date(e.target.value))}
className="mt-2 text-sm font-bold text-slate-700 bg-slate-100 rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</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 className="h-px bg-slate-100 w-full"></div>
<div className="flex flex-col md:flex-row gap-8 justify-between items-start">
{/* Destacado */}
<label className="flex items-center gap-4 cursor-pointer group">
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${isFeatured ? 'bg-amber-400' : 'bg-slate-200'}`}>
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${isFeatured ? 'translate-x-6' : ''}`} />
</div>
<div>
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-amber-500 transition-colors">Destacar Aviso</h4>
<p className="text-[10px] text-slate-400 font-bold">Posición preferencial (+ $500/día)</p>
</div>
<input type="checkbox" className="hidden" checked={isFeatured} onChange={(e) => setIsFeatured(e.target.checked)} />
</label>
{/* Permitir Contacto */}
<label className="flex items-center gap-4 cursor-pointer group">
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${allowContact ? 'bg-green-500' : 'bg-slate-200'}`}>
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${allowContact ? 'translate-x-6' : ''}`} />
</div>
<div>
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-green-600 transition-colors">Permitir Contacto</h4>
<p className="text-[10px] text-slate-400 font-bold">Mostrar botón de WhatsApp/Email</p>
</div>
<input type="checkbox" className="hidden" checked={allowContact} onChange={(e) => setAllowContact(e.target.checked)} />
</label>
</div>
<div className="bg-slate-50 p-6 rounded-2xl flex items-center gap-4">
<span className="text-xs font-black uppercase text-slate-400">Cupón:</span>
<input
type="text"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value.toUpperCase())}
placeholder="CÓDIGO"
className="bg-transparent border-b-2 border-slate-200 focus:border-blue-500 outline-none font-mono font-bold text-slate-700 w-32 text-center uppercase"
/>
{pricing.discount > 0 && <span className="text-xs font-bold text-green-600">¡Aplicado!</span>}
</div>
</section>
{/* BLOQUE 3: PREVISUALIZACIÓN */}

View File

@@ -14,6 +14,12 @@ export interface Listing {
images?: ListingImage[];
viewCount: number;
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
publicationStartDate?: string;
approvedAt?: string;
isFeatured?: boolean;
featuredExpiry?: string;
allowContact?: boolean;
printDaysCount?: number;
}
export interface ListingAttribute {
@@ -77,4 +83,8 @@ export interface CreateListingDto {
printFontSize?: string;
printAlignment?: string;
imagesToClone?: string[];
publicationStartDate?: string;
isFeatured?: boolean;
allowContact?: boolean;
couponCode?: string;
}

View File

@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Application.Interfaces;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Entities;
namespace SIGCM.API.Controllers;
@@ -10,10 +12,12 @@ namespace SIGCM.API.Controllers;
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly AuditRepository _auditRepo;
public AuthController(IAuthService authService)
public AuthController(IAuthService authService, AuditRepository auditRepo)
{
_authService = authService;
_auditRepo = auditRepo;
}
// Inicio de sesión tradicional
@@ -22,6 +26,21 @@ public class AuthController : ControllerBase
{
var result = await _authService.LoginAsync(dto.Username, dto.Password);
if (!result.Success) return Unauthorized(new { message = result.ErrorMessage });
// Audit Log
if (result.UserId.HasValue)
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = result.UserId.Value,
Action = "LOGIN",
EntityId = result.UserId.Value,
EntityType = "User",
Details = $"Inicio de sesión exitoso: {dto.Username}",
CreatedAt = DateTime.UtcNow
});
}
return Ok(result);
}
@@ -63,6 +82,18 @@ public class AuthController : ControllerBase
if (!valid) return BadRequest(new { message = "Código inválido" });
await _authService.EnableMfaAsync(userId, true);
// Audit Log
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "ENABLE_MFA",
EntityId = userId,
EntityType = "User",
Details = "Autenticación de Dos Factores activada",
CreatedAt = DateTime.UtcNow
});
return Ok(new { success = true });
}
}

View File

@@ -35,7 +35,7 @@ public class CashSessionsController : ControllerBase
}
[HttpPost("open")]
public async Task<IActionResult> Open([FromBody] decimal openingBalance)
public async Task<IActionResult> Open([FromBody] CashOpeningDto dto)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
@@ -43,7 +43,7 @@ public class CashSessionsController : ControllerBase
var existing = await _repo.GetActiveSessionAsync(userId);
if (existing != null) return BadRequest("Ya tienes una caja abierta");
var id = await _repo.OpenSessionAsync(userId, openingBalance);
var id = await _repo.OpenSessionAsync(userId, dto.OpeningBalance);
// Opcional: Auditar la apertura
await _auditRepo.AddLogAsync(new AuditLog
@@ -52,7 +52,7 @@ public class CashSessionsController : ControllerBase
Action = "CASH_SESSION_OPENED",
EntityId = id,
EntityType = "CashSession",
Details = $"Caja abierta con fondo: ${openingBalance}",
Details = $"Caja abierta con fondo: ${dto.OpeningBalance}",
CreatedAt = DateTime.UtcNow
});

View File

@@ -53,6 +53,22 @@ public class CategoriesController : ControllerBase
var id = await _repository.AddAsync(category);
category.Id = id;
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CREATE_CATEGORY",
EntityId = id,
EntityType = "Category",
Details = $"Rubro '{category.Name}' creado (Padre ID: {category.ParentId})",
CreatedAt = DateTime.UtcNow
});
}
return CreatedAtAction(nameof(GetById), new { id }, category);
}
@@ -71,6 +87,22 @@ public class CategoriesController : ControllerBase
}
await _repository.UpdateAsync(category);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "UPDATE_CATEGORY",
EntityId = id,
EntityType = "Category",
Details = $"Rubro ID #{id} actualizado a '{category.Name}'",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
@@ -127,6 +159,22 @@ public class CategoriesController : ControllerBase
{
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "MERGE_CATEGORIES",
EntityId = request.TargetId,
EntityType = "Category",
Details = $"Rubro ID #{request.SourceId} fusionado en ID #{request.TargetId}",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Fusión completada." });
}
@@ -143,6 +191,22 @@ public class CategoriesController : ControllerBase
}
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "MOVE_CONTENT",
EntityId = request.TargetId,
EntityType = "Listing",
Details = $"Avisos movidos del Rubro ID #{request.SourceId} al ID #{request.TargetId}",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Avisos movidos correctamente." });
}

View File

@@ -10,10 +10,12 @@ namespace SIGCM.API.Controllers;
public class ClientsController : ControllerBase
{
private readonly ClientRepository _repo;
private readonly AuditRepository _auditRepo;
public ClientsController(ClientRepository repo)
public ClientsController(ClientRepository repo, AuditRepository auditRepo)
{
_repo = repo;
_auditRepo = auditRepo;
}
[HttpGet("search")]
@@ -36,6 +38,22 @@ public class ClientsController : ControllerBase
{
if (id != client.Id) return BadRequest("ID de URL no coincide con el cuerpo");
await _repo.UpdateAsync(client);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "UPDATE_CLIENT",
EntityId = id,
EntityType = "Client",
Details = $"Cliente {client.Name} actualizado (DNI/CUIT: {client.DniOrCuit})",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}

View File

@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin")]
public class CouponsController : ControllerBase
{
private readonly ICouponRepository _repository;
private readonly AuditRepository _auditRepo;
public CouponsController(ICouponRepository repository, AuditRepository auditRepo)
{
_repository = repository;
_auditRepo = auditRepo;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var coupons = await _repository.GetAllAsync();
return Ok(coupons);
}
[HttpPost]
public async Task<IActionResult> Create(CreateCouponDto dto)
{
// Simple manual mapping
var coupon = new Coupon
{
Code = dto.Code.ToUpper(),
DiscountType = dto.DiscountType,
DiscountValue = dto.DiscountValue,
ExpiryDate = dto.ExpiryDate,
MaxUsages = dto.MaxUsages,
MaxUsagesPerUser = dto.MaxUsagesPerUser,
IsActive = true
};
var id = await _repository.CreateAsync(coupon);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CREATE_COUPON",
EntityId = id,
EntityType = "Coupon",
Details = $"Cupón {coupon.Code} creado ({dto.DiscountType}: {dto.DiscountValue})",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { id });
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _repository.DeleteAsync(id);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "DELETE_COUPON",
EntityId = id,
EntityType = "Coupon",
Details = $"Cupón ID #{id} eliminado",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
}
public class CreateCouponDto
{
public required string Code { get; set; }
public required string DiscountType { get; set; } // "Percentage" or "Fixed"
public decimal DiscountValue { get; set; }
public DateTime? ExpiryDate { get; set; }
public int? MaxUsages { get; set; }
public int? MaxUsagesPerUser { get; set; }
}

View File

@@ -14,13 +14,17 @@ public class ListingsController : ControllerBase
{
private readonly IListingRepository _repository;
private readonly ClientRepository _clientRepo;
private readonly IUserRepository _userRepo;
private readonly AuditRepository _auditRepo;
private readonly ICouponRepository _couponRepo;
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
public ListingsController(IListingRepository repository, ClientRepository clientRepo, IUserRepository userRepo, AuditRepository auditRepo, ICouponRepository couponRepo)
{
_repository = repository;
_clientRepo = clientRepo;
_userRepo = userRepo;
_auditRepo = auditRepo;
_couponRepo = couponRepo;
}
[HttpPost]
@@ -37,12 +41,29 @@ public class ListingsController : ControllerBase
}
int? clientId = null;
var user = await _userRepo.GetByIdAsync(currentUserId);
// Lógica de Cliente (asegurar existencia en BD)
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
// Lógica de Vinculación Usuario-Cliente
if (user != null)
{
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
clientId = user.ClientId;
// Si el usuario no tiene cliente vinculado, pero envía datos de facturación (o registramos los que tiene)
if (clientId == null)
{
string? dniToUse = dto.ClientDni ?? user.BillingTaxId;
if (!string.IsNullOrWhiteSpace(dniToUse))
{
string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username;
clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!);
// Actualizamos el usuario con su nuevo nexo de cliente
user.ClientId = clientId;
user.BillingTaxId = dniToUse;
user.BillingName = nameToUse;
await _userRepo.UpdateAsync(user);
}
}
}
var listing = new Listing
@@ -56,19 +77,43 @@ public class ListingsController : ControllerBase
Currency = "ARS",
UserId = currentUserId,
ClientId = clientId,
Origin = dto.Origin,
Origin = dto.Origin ?? "Web",
Status = dto.Status,
ImagesToClone = dto.ImagesToClone,
CreatedAt = DateTime.UtcNow,
PrintText = dto.PrintText,
PrintDaysCount = dto.PrintDaysCount,
PrintStartDate = dto.PrintStartDate,
PrintStartDate = dto.PrintStartDate ?? dto.PublicationStartDate,
IsBold = dto.IsBold,
IsFrame = dto.IsFrame,
PrintFontSize = dto.PrintFontSize,
PrintAlignment = dto.PrintAlignment
PrintAlignment = dto.PrintAlignment,
// Nuevos Campos
PublicationStartDate = dto.PublicationStartDate,
IsFeatured = dto.IsFeatured,
AllowContact = dto.AllowContact,
CouponCode = dto.CouponCode
};
// Validar límite de uso de cupón por usuario
if (!string.IsNullOrEmpty(dto.CouponCode))
{
var coupon = await _couponRepo.GetByCodeAsync(dto.CouponCode);
if (coupon != null && coupon.MaxUsagesPerUser.HasValue)
{
// Contar usos previos
var userUsageCount = await _couponRepo.CountUserUsageAsync(currentUserId, coupon.Code);
if (userUsageCount >= coupon.MaxUsagesPerUser.Value)
{
return BadRequest(new { message = $"El cupón {dto.CouponCode} ya ha superado el límite de uso para este usuario." });
}
}
}
// Lógica de fecha de inicio: Si el usuario solicitó una fecha futura, ApprovedAt permanece null hasta esa fecha.
// La lógica actual establece ApprovedAt al aprobar en moderación.
// PublicationStartDate es solo una referencia de solicitud.
// Conversión de Pagos
List<Payment>? payments = null;
if (dto.Payments != null && dto.Payments.Any())
@@ -85,6 +130,16 @@ public class ListingsController : ControllerBase
var id = await _repository.CreateAsync(listing, dto.Attributes, payments);
// Coupon Usage Logic
if (!string.IsNullOrEmpty(dto.CouponCode))
{
var coupon = await _couponRepo.GetByCodeAsync(dto.CouponCode);
if (coupon != null)
{
await _couponRepo.IncrementUsageAsync(coupon.Id);
}
}
// Registro de Auditoría
await _auditRepo.AddLogAsync(new AuditLog
{
@@ -92,7 +147,7 @@ public class ListingsController : ControllerBase
Action = "CREATE_LISTING",
EntityId = id,
EntityType = "Listing",
Details = $"Aviso creado por usuario autenticado. Total: ${dto.AdFee}",
Details = $"Aviso creado por usuario autenticado. Total: ${dto.AdFee}. Featured: {dto.IsFeatured}",
CreatedAt = DateTime.UtcNow
});
@@ -144,7 +199,8 @@ public class ListingsController : ControllerBase
request.From,
request.To,
request.Origin,
request.Status
request.Status,
request.OnlyActive
);
return Ok(results);
}
@@ -159,28 +215,41 @@ public class ListingsController : ControllerBase
[HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request)
{
// Obtener el ID de quien realiza la acción (Administrador/Moderador)
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId))
return Unauthorized();
await _repository.UpdateStatusAsync(id, status);
await _repository.UpdateStatusAsync(id, request.Status);
string actionDetails = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {request.Status}";
if (!string.IsNullOrEmpty(request.Note))
{
actionDetails += $". Nota al usuario: {request.Note}";
// TODO: Create actual Notification record for user
}
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId,
Action = status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
Action = request.Status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
EntityId = id,
EntityType = "Listing",
Details = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {status}",
Details = actionDetails,
CreatedAt = DateTime.UtcNow
});
return Ok();
}
public class UpdateStatusRequest
{
public required string Status { get; set; }
public string? Note { get; set; }
}
public class SearchRequest
{
public string? Query { get; set; }
@@ -190,6 +259,7 @@ public class ListingsController : ControllerBase
public DateTime? To { get; set; }
public string? Origin { get; set; }
public string? Status { get; set; }
public bool OnlyActive { get; set; } = false;
}
[HttpGet("pending/count")]

View File

@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/listings/{listingId}/[controller]")]
[Authorize(Roles = "Admin,Moderador")]
public class NotesController : ControllerBase
{
private readonly IListingNoteRepository _noteRepo;
public NotesController(IListingNoteRepository noteRepo)
{
_noteRepo = noteRepo;
}
[HttpPost]
public async Task<IActionResult> CreateNote(int listingId, [FromBody] CreateNoteDto dto)
{
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int moderatorId))
return Unauthorized();
var note = new ListingNote
{
ListingId = listingId,
SenderId = moderatorId,
IsFromModerator = true,
Message = dto.Message
};
var id = await _noteRepo.CreateAsync(note);
return Ok(new { id });
}
[HttpGet]
public async Task<IActionResult> GetByListing(int listingId)
{
var notes = await _noteRepo.GetByListingIdAsync(listingId);
// Marcar como leídos los mensajes que NO son de moderador (respuestas del usuario)
foreach (var note in notes.Where(n => !n.IsFromModerator && !n.IsRead))
{
await _noteRepo.MarkAsReadAsync(note.Id);
}
return Ok(notes);
}
}
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Moderador")]
public class ModerationStatsController : ControllerBase
{
private readonly IListingNoteRepository _noteRepo;
public ModerationStatsController(IListingNoteRepository noteRepo)
{
_noteRepo = noteRepo;
}
[HttpGet("unread-count")]
public async Task<IActionResult> GetUnreadCount()
{
var count = await _noteRepo.GetUnreadCountAsync(0, true);
return Ok(count);
}
}
public class CreateNoteDto
{
public required string Message { get; set; }
}

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Services;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
@@ -13,11 +14,13 @@ public class PaymentsController : ControllerBase
{
private readonly MercadoPagoService _mpService;
private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo)
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo, AuditRepository auditRepo)
{
_mpService = mpService;
_listingRepo = listingRepo;
_auditRepo = auditRepo;
}
/*
@@ -73,6 +76,22 @@ public class PaymentsController : ControllerBase
};
await _listingRepo.AddPaymentAsync(paymentRecord);
// Audit Log
var userIdStr = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdStr, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CONFIRM_PAYMENT",
EntityId = listingId,
EntityType = "Payment",
Details = $"Pago confirmado (Simulado). Monto: ${request.Amount}",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Pago registrado. Aviso enviado a moderación." });
}

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProfileController : ControllerBase
{
private readonly IUserRepository _userRepo;
public ProfileController(IUserRepository userRepo)
{
_userRepo = userRepo;
}
[HttpGet]
public async Task<IActionResult> GetProfile()
{
var userIdStr = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdStr, out int userId)) return Unauthorized();
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) return NotFound();
return Ok(new
{
user.Username,
user.Email,
user.BillingName,
user.BillingAddress,
user.BillingTaxId,
user.BillingTaxType
});
}
[HttpPut]
public async Task<IActionResult> UpdateProfile(UpdateProfileDto dto)
{
var userIdStr = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdStr, out int userId)) return Unauthorized();
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) return NotFound();
user.Email = dto.Email;
user.BillingName = dto.BillingName;
user.BillingAddress = dto.BillingAddress;
user.BillingTaxId = dto.BillingTaxId;
user.BillingTaxType = dto.BillingTaxType;
await _userRepo.UpdateAsync(user);
return Ok(new
{
user.Username,
user.Email,
user.BillingName,
user.BillingAddress,
user.BillingTaxId,
user.BillingTaxType
});
}
}

View File

@@ -51,10 +51,12 @@ public class ReportsController : ControllerBase
[HttpGet("audit")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAuditLogs()
public async Task<IActionResult> GetAuditLogs([FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? userId)
{
// Obtenemos los últimos 100 eventos
var logs = await _auditRepo.GetRecentLogsAsync(100);
var start = from ?? DateTime.UtcNow.Date;
var end = to ?? DateTime.UtcNow.AddDays(1).Date.AddSeconds(-1);
var logs = await _auditRepo.GetFilteredLogsAsync(start, end, userId);
return Ok(logs);
}

View File

@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UserNotesController : ControllerBase
{
private readonly IListingNoteRepository _noteRepo;
public UserNotesController(IListingNoteRepository noteRepo)
{
_noteRepo = noteRepo;
}
[HttpGet("my-notes")]
public async Task<IActionResult> GetMyNotes()
{
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var notes = await _noteRepo.GetByUserIdAsync(userId);
return Ok(notes);
}
[HttpGet("listing/{listingId}")]
public async Task<IActionResult> GetByListing(int listingId)
{
var notes = await _noteRepo.GetByListingIdAsync(listingId);
// Marcar como leídos los mensajes que SON de moderador para este usuario
foreach (var note in notes.Where(n => n.IsFromModerator && !n.IsRead))
{
await _noteRepo.MarkAsReadAsync(note.Id);
}
return Ok(notes);
}
[HttpPost("listing/{listingId}/reply")]
public async Task<IActionResult> Reply(int listingId, [FromBody] ReplyDto dto)
{
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var note = new ListingNote
{
ListingId = listingId,
SenderId = userId,
IsFromModerator = false,
Message = dto.Message
};
var id = await _noteRepo.CreateAsync(note);
return Ok(new { id });
}
[HttpGet("unread-count")]
public async Task<IActionResult> GetUnreadCount()
{
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var count = await _noteRepo.GetUnreadCountAsync(userId, false);
return Ok(count);
}
[HttpPut("{noteId}/mark-read")]
public async Task<IActionResult> MarkAsRead(int noteId)
{
await _noteRepo.MarkAsReadAsync(noteId);
return Ok();
}
}
public class ReplyDto
{
public required string Message { get; set; }
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
@@ -12,18 +13,22 @@ namespace SIGCM.API.Controllers;
public class UsersController : ControllerBase
{
private readonly IUserRepository _repository;
private readonly AuditRepository _auditRepo;
public UsersController(IUserRepository repository)
public UsersController(IUserRepository repository, AuditRepository auditRepo)
{
_repository = repository;
_auditRepo = auditRepo;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var users = await _repository.GetAllAsync();
// Don't expose password hashes
var sanitized = users.Select(u => new {
// Excluimos clientes para que solo aparezcan en su propio gestor
var sanitized = users
.Where(u => u.Role != "Client")
.Select(u => new {
u.Id, u.Username, u.Role, u.Email, u.CreatedAt
});
return Ok(sanitized);
@@ -57,6 +62,22 @@ public class UsersController : ControllerBase
};
var id = await _repository.CreateAsync(user);
// Audit Log
var currentUserIdStr = User.FindFirst("Id")?.Value;
if (int.TryParse(currentUserIdStr, out int currentUserId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId,
Action = "CREATE_USER",
EntityId = id,
EntityType = "User",
Details = $"Usuario {dto.Username} creado con rol {dto.Role}",
CreatedAt = DateTime.UtcNow
});
}
return CreatedAtAction(nameof(GetById), new { id }, new { id, user.Username });
}
@@ -76,6 +97,22 @@ public class UsersController : ControllerBase
}
await _repository.UpdateAsync(user);
// Audit Log
var currentUserIdStr = User.FindFirst("Id")?.Value;
if (int.TryParse(currentUserIdStr, out int currentUserId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId,
Action = "UPDATE_USER",
EntityId = id,
EntityType = "User",
Details = $"Usuario {user.Username} actualizado (Rol: {user.Role})",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
@@ -85,6 +122,22 @@ public class UsersController : ControllerBase
// Safe check: prevent deleting yourself optional but good practice
// For now simple delete
await _repository.DeleteAsync(id);
// Audit Log
var currentUserIdStr = User.FindFirst("Id")?.Value;
if (int.TryParse(currentUserIdStr, out int currentUserId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId,
Action = "DELETE_USER",
EntityId = id,
EntityType = "User",
Details = $"Usuario ID #{id} eliminado",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -8,4 +8,5 @@ public class AuthResult
public bool IsLockedOut { get; set; }
public bool RequiresPasswordChange { get; set; }
public bool RequiresMfa { get; set; }
public int? UserId { get; set; }
}

View File

@@ -10,6 +10,11 @@ public class CashClosingDto
public string? Notes { get; set; }
}
public class CashOpeningDto
{
public decimal OpeningBalance { get; set; }
}
// DTO de respuesta con el resultado del cierre
public class CashClosingResultDto
{

View File

@@ -27,6 +27,12 @@ public class CreateListingDto
public string PrintFontSize { get; set; } = "normal";
public string PrintAlignment { get; set; } = "left";
// Public Web Features
public DateTime? PublicationStartDate { get; set; }
public bool IsFeatured { get; set; }
public bool AllowContact { get; set; } = true;
public string? CouponCode { get; set; }
// Atributos Dinámicos
public Dictionary<int, string> Attributes { get; set; } = new();

View File

@@ -7,7 +7,9 @@ public class CalculatePriceRequest
public int Days { get; set; }
public bool IsBold { get; set; }
public bool IsFrame { get; set; }
public bool IsFeatured { get; set; }
public DateTime StartDate { get; set; }
public string? CouponCode { get; set; }
}
public class CalculatePriceResponse

View File

@@ -15,3 +15,12 @@ public class UpdateUserDto
public required string Role { get; set; }
public string? Email { get; set; }
}
public class UpdateProfileDto
{
public string? Email { get; set; }
public string? BillingName { get; set; }
public string? BillingAddress { get; set; }
public string? BillingTaxId { get; set; }
public string? BillingTaxType { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM.Domain.Entities;
public class Coupon
{
public int Id { get; set; }
public required string Code { get; set; }
public required string DiscountType { get; set; } // "Percentage", "Fixed"
public decimal DiscountValue { get; set; }
public DateTime? ExpiryDate { get; set; }
public int UsageCount { get; set; }
public int? MaxUsages { get; set; }
public int? MaxUsagesPerUser { get; set; } // New: Limit per user
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -30,5 +30,15 @@ public class Listing
// Propiedades auxiliares
public string? CategoryName { get; set; }
public string? ParentCategoryName { get; set; }
public string? MainImageUrl { get; set; }
// Propiedades de Publicación y Gestión
public DateTime? PublicationStartDate { get; set; } // Fecha inicio solicitada por usuario
public DateTime? ApprovedAt { get; set; } // Fecha real de aprobación (inicio de contador de días)
public bool IsFeatured { get; set; }
public DateTime? FeaturedExpiry { get; set; }
public bool AllowContact { get; set; } = true;
public string? CouponCode { get; set; }
public int UnreadNotesCount { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM.Domain.Entities;
public class ListingNote
{
public int Id { get; set; }
public int ListingId { get; set; }
public int SenderId { get; set; }
public bool IsFromModerator { get; set; }
public required string Message { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsRead { get; set; } = false;
}

View File

@@ -20,4 +20,12 @@ public class User
public string? GoogleId { get; set; }
public bool IsMfaEnabled { get; set; }
public string? MfaSecret { get; set; }
// Datos de Facturación y Contacto (Unificados con Clientes)
public int? ClientId { get; set; } // Opcional: ID de herencia o referencia externa
public string? BillingName { get; set; } // Equivale a Client.Name
public string? BillingAddress { get; set; } // Equivale a Client.Address
public string? BillingTaxId { get; set; } // CUIT/DNI
public string? BillingTaxType { get; set; } // Condición fiscal
public string? Phone { get; set; } // Nuevo: Teléfono de contacto
}

View File

@@ -0,0 +1,13 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface ICouponRepository
{
Task<IEnumerable<Coupon>> GetAllAsync();
Task<int> CreateAsync(Coupon coupon);
Task DeleteAsync(int id);
Task<Coupon?> GetByCodeAsync(string code);
Task IncrementUsageAsync(int id);
Task<int> CountUserUsageAsync(int userId, string couponCode);
}

View File

@@ -0,0 +1,12 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface IListingNoteRepository
{
Task<int> CreateAsync(ListingNote note);
Task<IEnumerable<ListingNote>> GetByListingIdAsync(int listingId);
Task<IEnumerable<ListingNote>> GetByUserIdAsync(int userId);
Task MarkAsReadAsync(int noteId);
Task<int> GetUnreadCountAsync(int userId, bool isForModerator);
}

View File

@@ -27,7 +27,8 @@ public interface IListingRepository
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null
string? status = null,
bool onlyActive = false
);
// Impresión

View File

@@ -26,4 +26,5 @@ public class ReportItemDto
public string Cashier { get; set; } = "";
public decimal Amount { get; set; }
public string Source { get; set; } = "";
public string? ClientName { get; set; }
}

View File

@@ -116,9 +116,181 @@ BEGIN
);
END
";
// Ejecutar creación de tablas
// Ejecutar creación de tablas base
await connection.ExecuteAsync(schemaSql);
// --- MIGRACIONES (Schema Update) ---
var migrationSql = @"
-- Listings Columns
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ApprovedAt' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD ApprovedAt DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFeatured' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD IsFeatured BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FeaturedExpiry' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD FeaturedExpiry DATETIME2 NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'AllowContact' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD AllowContact BIT NOT NULL DEFAULT 1;
-- Users Columns (Security & Profiles)
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingName' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingName NVARCHAR(200) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingAddress' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingAddress NVARCHAR(500) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingTaxId NVARCHAR(50) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxType' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD BillingTaxType NVARCHAR(50) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ClientId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD ClientId INT NULL;
-- Audit & Security Columns for Users
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FailedLoginAttempts' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD FailedLoginAttempts INT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LockoutEnd' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD LockoutEnd DATETIME NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MustChangePassword' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD MustChangePassword BIT NOT NULL DEFAULT 1;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsActive' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD IsActive BIT NOT NULL DEFAULT 1;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LastLogin' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD LastLogin DATETIME NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'GoogleId' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD GoogleId NVARCHAR(255) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsMfaEnabled' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD IsMfaEnabled BIT NOT NULL DEFAULT 0;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MfaSecret' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD MfaSecret NVARCHAR(255) NULL;
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Users'))
ALTER TABLE Users ADD Phone NVARCHAR(50) NULL;
-- Coupons Table
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Coupons')
BEGIN
CREATE TABLE Coupons (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code NVARCHAR(50) NOT NULL,
DiscountType NVARCHAR(20) NOT NULL,
DiscountValue DECIMAL(18,2) NOT NULL,
ExpiryDate DATETIME2 NULL,
UsageCount INT NOT NULL DEFAULT 0,
MaxUsages INT NULL,
IsActive BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
CREATE UNIQUE INDEX IX_Coupons_Code ON Coupons(Code);
-- Seed Default Coupon
INSERT INTO Coupons (Code, DiscountType, DiscountValue) VALUES ('WELCOME2025', 'Percentage', 10.00);
END
-- Listings CouponCode
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'CouponCode' AND Object_ID = Object_ID(N'Listings'))
ALTER TABLE Listings ADD CouponCode NVARCHAR(50) NULL;
-- Coupons MaxUsagesPerUser
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MaxUsagesPerUser' AND Object_ID = Object_ID(N'Coupons'))
ALTER TABLE Coupons ADD MaxUsagesPerUser INT NULL;
-- Tabla de Notas/Mensajes de Avisos
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'ListingNotes') AND type in (N'U'))
BEGIN
CREATE TABLE ListingNotes (
Id INT IDENTITY(1,1) PRIMARY KEY,
ListingId INT NOT NULL,
SenderId INT NOT NULL,
IsFromModerator BIT NOT NULL DEFAULT 1,
Message NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
IsRead BIT NOT NULL DEFAULT 0,
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE,
FOREIGN KEY (SenderId) REFERENCES Users(Id)
);
END
ELSE
BEGIN
-- Migración gradual si la tabla ya existía con ModeratorId
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'ModeratorId' AND Object_ID = Object_ID(N'ListingNotes'))
BEGIN
EXEC sp_rename 'ListingNotes.ModeratorId', 'SenderId', 'COLUMN';
END
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFromModerator' AND Object_ID = Object_ID(N'ListingNotes'))
BEGIN
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
END
END
";
await connection.ExecuteAsync(migrationSql);
// --- MIGRACIÓN DE DATOS (Post-Schema Update) ---
var dataMigrationSql = @"
-- Data Migration: Clients to Users
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'Clients')
BEGIN
-- Verificamos si la tabla Clients tiene la columna Phone para evitar errores
DECLARE @phoneColumnExists BIT = 0;
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Clients'))
SET @phoneColumnExists = 1;
IF @phoneColumnExists = 1
BEGIN
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, Phone, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name) as Username,
'N/A' as PasswordHash,
'Client' as Role,
Email,
Name as BillingName,
DniOrCuit as BillingTaxId,
Address as BillingAddress,
Phone,
0 as MustChangePassword,
1 as IsActive
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
END
ELSE
BEGIN
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name) as Username,
'N/A' as PasswordHash,
'Client' as Role,
Email,
Name as BillingName,
DniOrCuit as BillingTaxId,
Address as BillingAddress,
0 as MustChangePassword,
1 as IsActive
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
END
-- 1. Actualizar Listings para que apunten a Users antes de romper nada (opcional pero recomendado si hay datos)
-- Solo lo hacemos para los que ya existen en Users por BillingTaxId
UPDATE l
SET l.ClientId = u.Id
FROM Listings l
JOIN Clients c ON l.ClientId = c.Id
JOIN Users u ON c.DniOrCuit = u.BillingTaxId;
-- 2. Eliminar todas las llaves foráneas que apuntan a Clients
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) +
' DROP CONSTRAINT ' + QUOTENAME(name) + ';'
FROM sys.foreign_keys
WHERE referenced_object_id = OBJECT_ID('Clients');
IF @sql <> '' EXEC sp_executesql @sql;
-- 3. Finalmente borrar la tabla
DROP TABLE Clients;
END
";
await connection.ExecuteAsync(dataMigrationSql);
// --- SEED DE DATOS (Usuario Admin) ---
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");

View File

@@ -0,0 +1,69 @@
-- Run this script to update the database schema
-- Update Listings table
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
ALTER TABLE Listings ADD ApprovedAt DATETIME2 NULL;
ALTER TABLE Listings ADD IsFeatured BIT NOT NULL DEFAULT 0;
ALTER TABLE Listings ADD FeaturedExpiry DATETIME2 NULL;
ALTER TABLE Listings ADD AllowContact BIT NOT NULL DEFAULT 1;
-- Update Users table
ALTER TABLE Users ADD BillingName NVARCHAR(200) NULL;
ALTER TABLE Users ADD BillingAddress NVARCHAR(500) NULL;
ALTER TABLE Users ADD BillingTaxId NVARCHAR(50) NULL;
ALTER TABLE Users ADD BillingTaxType NVARCHAR(50) NULL;
ALTER TABLE Users ADD ClientId INT NULL;
ALTER TABLE Users ADD FailedLoginAttempts INT NOT NULL DEFAULT 0;
ALTER TABLE Users ADD LockoutEnd DATETIME NULL;
ALTER TABLE Users ADD MustChangePassword BIT NOT NULL DEFAULT 1;
ALTER TABLE Users ADD IsActive BIT NOT NULL DEFAULT 1;
ALTER TABLE Users ADD LastLogin DATETIME NULL;
ALTER TABLE Users ADD GoogleId NVARCHAR(255) NULL;
ALTER TABLE Users ADD IsMfaEnabled BIT NOT NULL DEFAULT 0;
ALTER TABLE Users ADD MfaSecret NVARCHAR(255) NULL;
ALTER TABLE Users ADD Phone NVARCHAR(50) NULL;
-- Create Coupons table
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='Coupons' and xtype='U')
BEGIN
CREATE TABLE Coupons (
Id INT IDENTITY(1,1) PRIMARY KEY,
Code NVARCHAR(50) NOT NULL,
DiscountType NVARCHAR(20) NOT NULL,
DiscountValue DECIMAL(18,2) NOT NULL,
ExpiryDate DATETIME2 NULL,
UsageCount INT NOT NULL DEFAULT 0,
MaxUsages INT NULL,
IsActive BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
);
CREATE UNIQUE INDEX IX_Coupons_Code ON Coupons(Code);
END
-- Data Migration and Consolidation
-- Copy clients to users if they don't exist
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, Phone, MustChangePassword, IsActive)
SELECT
ISNULL(DniOrCuit, Name), 'N/A', 'Client', Email, Name, DniOrCuit, Address, Phone, 0, 1
FROM Clients
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
-- Update Listings to point to new User IDs
UPDATE l
SET l.ClientId = u.Id
FROM Listings l
JOIN Clients c ON l.ClientId = c.Id
JOIN Users u ON c.DniOrCuit = u.BillingTaxId;
-- Drop foreign keys referencing Clients
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) +
' DROP CONSTRAINT ' + QUOTENAME(name) + ';'
FROM sys.foreign_keys
WHERE referenced_object_id = OBJECT_ID('Clients');
IF @sql <> '' EXEC sp_executesql @sql;
-- DROP obsolete table
IF EXISTS (SELECT * FROM sysobjects WHERE name='Clients' and xtype='U')
DROP TABLE Clients;

View File

@@ -32,6 +32,8 @@ public static class DependencyInjection
services.AddScoped<ImageOptimizationService>();
services.AddScoped<IClaimRepository, ClaimRepository>();
services.AddScoped<NotificationRepository>();
services.AddScoped<ICouponRepository, CouponRepository>();
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp =>

View File

@@ -37,4 +37,19 @@ public class AuditRepository
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
}
public async Task<IEnumerable<AuditLog>> GetFilteredLogsAsync(DateTime from, DateTime to, int? userId = null)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
WHERE a.CreatedAt >= @From AND a.CreatedAt <= @To";
if (userId.HasValue) sql += " AND a.UserId = @UserId";
sql += " ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { From = from, To = to, UserId = userId });
}
}

View File

@@ -13,59 +13,63 @@ public class ClientRepository
_db = db;
}
// Búsqueda inteligente con protección de nulos
// Búsqueda inteligente redireccionada a Users
public async Task<IEnumerable<Client>> SearchAsync(string query)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP 10
Id,
ISNULL(Name, 'Sin Nombre') as Name,
ISNULL(DniOrCuit, '') as DniOrCuit,
Email, Phone, Address
FROM Clients
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
ORDER BY Name";
ISNULL(BillingName, Username) as Name,
ISNULL(BillingTaxId, '') as DniOrCuit,
Email, Phone, BillingAddress as Address
FROM Users
WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query
ORDER BY BillingName";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
}
// Asegurar existencia (Upsert)
// Asegurar existencia (Upsert en la tabla Users)
public async Task<int> EnsureClientExistsAsync(string name, string dni)
{
using var conn = _db.CreateConnection();
var existingId = await conn.ExecuteScalarAsync<int?>(
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
"SELECT Id FROM Users WHERE BillingTaxId = @Dni", new { Dni = dni });
if (existingId.HasValue)
{
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
await conn.ExecuteAsync("UPDATE Users SET BillingName = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
return existingId.Value;
}
else
{
// Si no existe, creamos un usuario con rol Cliente (sin password por ahora, es solo para gestión de mostrador)
var sql = @"
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
INSERT INTO Users (Username, Role, BillingName, BillingTaxId, PasswordHash, MustChangePassword)
VALUES (@Username, 'Client', @Name, @Dni, 'N/A', 0);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
// El username será el DNI para asegurar unicidad si no hay otro dato
return await conn.QuerySingleAsync<int>(sql, new { Username = dni, Name = name, Dni = dni });
}
}
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
// Obtener todos con estadísticas desde Users
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT
c.Id as id,
ISNULL(c.Name, 'Sin Nombre') as name,
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit,
ISNULL(c.Email, 'Sin correo') as email,
ISNULL(c.Phone, 'Sin teléfono') as phone,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent
FROM Clients c
ORDER BY c.Name";
u.Id as id,
ISNULL(u.BillingName, u.Username) as name,
ISNULL(u.BillingTaxId, 'S/D') as dniOrCuit,
ISNULL(u.Email, 'Sin correo') as email,
ISNULL(u.Phone, 'Sin teléfono') as phone,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = u.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = u.Id), 0) as totalSpent
FROM Users u
WHERE Role IN ('Client', 'User') -- Mostramos tanto clientes puros como usuarios web
ORDER BY name";
return await conn.QueryAsync(sql);
}
@@ -73,12 +77,12 @@ public class ClientRepository
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE Clients
SET Name = @Name,
DniOrCuit = @DniOrCuit,
UPDATE Users
SET BillingName = @Name,
BillingTaxId = @DniOrCuit,
Email = @Email,
Phone = @Phone,
Address = @Address
BillingAddress = @Address
WHERE Id = @Id";
await conn.ExecuteAsync(sql, client);
}
@@ -88,21 +92,23 @@ public class ClientRepository
using var conn = _db.CreateConnection();
var sql = @"
SELECT
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds,
u.Id,
ISNULL(u.BillingName, u.Username) as Name,
u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address,
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id) as TotalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = u.Id), 0) as TotalInvested,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = u.Id) as LastAdDate,
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id AND Status = 'Published') as ActiveAds,
ISNULL((
SELECT TOP 1 cat.Name
FROM Listings l
JOIN Categories cat ON l.CategoryId = cat.Id
WHERE l.ClientId = c.Id
WHERE l.ClientId = u.Id
GROUP BY cat.Name
ORDER BY COUNT(l.Id) DESC
), 'N/A') as PreferredCategory
FROM Clients c
WHERE c.Id = @Id";
FROM Users u
WHERE u.Id = @Id";
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
}

View File

@@ -0,0 +1,63 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CouponRepository : ICouponRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public CouponRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IEnumerable<Coupon>> GetAllAsync()
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QueryAsync<Coupon>("SELECT * FROM Coupons ORDER BY CreatedAt DESC");
}
public async Task<int> CreateAsync(Coupon coupon)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Coupons (Code, DiscountType, DiscountValue, ExpiryDate, MaxUsages, MaxUsagesPerUser, IsActive, CreatedAt)
VALUES (@Code, @DiscountType, @DiscountValue, @ExpiryDate, @MaxUsages, @MaxUsagesPerUser, @IsActive, GETUTCDATE());
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, coupon);
}
public async Task DeleteAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("DELETE FROM Coupons WHERE Id = @Id", new { Id = id });
}
public async Task<Coupon?> GetByCodeAsync(string code)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<Coupon>(
"SELECT * FROM Coupons WHERE Code = @Code AND IsActive = 1",
new { Code = code });
}
public async Task IncrementUsageAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(
"UPDATE Coupons SET UsageCount = UsageCount + 1 WHERE Id = @Id",
new { Id = id });
}
public async Task<int> CountUserUsageAsync(int userId, string couponCode)
{
using var conn = _connectionFactory.CreateConnection();
// Check listings for this user with this coupon code
// Note: CouponCode column was added to Listings
var sql = "SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CouponCode = @CouponCode";
return await conn.ExecuteScalarAsync<int>(sql, new { UserId = userId, CouponCode = couponCode });
}
}

View File

@@ -0,0 +1,72 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ListingNoteRepository : IListingNoteRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public ListingNoteRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> CreateAsync(ListingNote note)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO ListingNotes (ListingId, SenderId, IsFromModerator, Message, CreatedAt, IsRead)
VALUES (@ListingId, @SenderId, @IsFromModerator, @Message, GETUTCDATE(), 0);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, note);
}
public async Task<IEnumerable<ListingNote>> GetByListingIdAsync(int listingId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = "SELECT * FROM ListingNotes WHERE ListingId = @ListingId ORDER BY CreatedAt ASC";
return await conn.QueryAsync<ListingNote>(sql, new { ListingId = listingId });
}
public async Task<IEnumerable<ListingNote>> GetByUserIdAsync(int userId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT ln.*
FROM ListingNotes ln
INNER JOIN Listings l ON ln.ListingId = l.Id
WHERE l.UserId = @UserId
ORDER BY ln.CreatedAt DESC";
return await conn.QueryAsync<ListingNote>(sql, new { UserId = userId });
}
public async Task MarkAsReadAsync(int noteId)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE ListingNotes SET IsRead = 1 WHERE Id = @Id", new { Id = noteId });
}
public async Task<int> GetUnreadCountAsync(int userId, bool isForModerator)
{
using var conn = _connectionFactory.CreateConnection();
var sql = "";
if (isForModerator)
{
// Para moderadores: Mensajes que NO son de moderador y NO están leídos
sql = @"SELECT COUNT(*) FROM ListingNotes WHERE IsFromModerator = 0 AND IsRead = 0";
}
else
{
// Para usuarios: Mensajes que SON de moderador, para sus avisos, y NO están leídos
sql = @"
SELECT COUNT(*)
FROM ListingNotes ln
INNER JOIN Listings l ON ln.ListingId = l.Id
WHERE l.UserId = @UserId AND ln.IsFromModerator = 1 AND ln.IsRead = 0";
}
return await conn.ExecuteScalarAsync<int>(sql, new { UserId = userId });
}
}

View File

@@ -29,12 +29,14 @@ public class ListingRepository : IListingRepository
INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId,
PublicationStartDate, IsFeatured, FeaturedExpiry, AllowContact, CouponCode, Origin
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId,
@PublicationStartDate, @IsFeatured, @FeaturedExpiry, @AllowContact, @CouponCode, @Origin
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -101,11 +103,15 @@ public class ListingRepository : IListingRepository
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
l.*,
c.Name as CategoryName,
p.Name as ParentCategoryName,
cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
u.Username as CashierName
FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
LEFT JOIN Categories p ON c.ParentId = p.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE l.Id = @Id;
@@ -142,10 +148,16 @@ public class ListingRepository : IListingRepository
CreatedAt = listingData.CreatedAt,
UserId = listingData.UserId,
CategoryName = listingData.CategoryName,
ParentCategoryName = listingData.ParentCategoryName,
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
PrintText = listingData.PrintText,
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
PublicationStartDate = listingData.PublicationStartDate,
ApprovedAt = listingData.ApprovedAt,
IsFeatured = listingData.IsFeatured != null && Convert.ToBoolean(listingData.IsFeatured),
FeaturedExpiry = listingData.FeaturedExpiry,
AllowContact = listingData.AllowContact != null && Convert.ToBoolean(listingData.AllowContact),
},
Attributes = attributes,
Images = images,
@@ -162,6 +174,8 @@ public class ListingRepository : IListingRepository
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
WHERE l.Status = 'Published'
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
@@ -190,7 +204,7 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
{
return await SearchFacetedAsync(query, categoryId, null);
return await SearchFacetedAsync(query, categoryId, null, null, null, null, null, true);
}
// Búsqueda Avanzada Facetada
@@ -201,23 +215,24 @@ public class ListingRepository : IListingRepository
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null)
string? status = null,
bool onlyActive = false)
{
using var conn = _connectionFactory.CreateConnection();
var parameters = new DynamicParameters();
string sql = @"
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
SELECT l.*, c.Name as CategoryName, cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
WHERE 1=1";
// --- FILTROS EXISTENTES ---
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.BillingTaxId = @ExactQuery OR cl.BillingName LIKE @Query)";
parameters.Add("Query", $"%{query}%");
parameters.Add("ExactQuery", query);
}
@@ -253,7 +268,16 @@ public class ListingRepository : IListingRepository
parameters.Add("Status", status);
}
sql += " ORDER BY l.CreatedAt DESC";
// --- FILTRO DE VISIBILIDAD (Solo para búsquedas públicas) ---
if (onlyActive)
{
// Solo avisos publicados y dentro de su rango de vigencia
sql += @" AND l.Status = 'Published'
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())";
}
sql += " ORDER BY l.IsFeatured DESC, l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql, parameters);
}
@@ -277,15 +301,28 @@ public class ListingRepository : IListingRepository
{
using var conn = _connectionFactory.CreateConnection();
// Avisos que NO están publicados ni rechazados ni borrados.
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión.
// Para este ejemplo, buscamos 'Pending'.
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC");
// Asumimos 'Pending'. Incluimos conteo de notas no leídas del usuario.
var sql = @"
SELECT l.*,
(SELECT COUNT(*) FROM ListingNotes ln WHERE ln.ListingId = l.Id AND ln.IsFromModerator = 0 AND ln.IsRead = 0) as UnreadNotesCount
FROM Listings l
WHERE l.Status = 'Pending'
ORDER BY l.CreatedAt ASC";
return await conn.QueryAsync<Listing>(sql);
}
public async Task UpdateStatusAsync(int id, string status)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE Listings SET Status = @Status WHERE Id = @Id", new { Id = id, Status = status });
var sql = @"
UPDATE Listings
SET Status = @Status,
ApprovedAt = CASE
WHEN (@Status = 'Published' OR @Status = 'Approved') AND ApprovedAt IS NULL THEN GETUTCDATE()
ELSE ApprovedAt
END
WHERE Id = @Id";
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
}
public async Task<int> CountByCategoryIdAsync(int categoryId)
@@ -562,10 +599,11 @@ public class ListingRepository : IListingRepository
SELECT
l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount,
l.Origin as Source
l.Origin as Source, cl.BillingName as ClientName
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
LEFT JOIN Users cl ON l.ClientId = cl.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId)

View File

@@ -46,8 +46,10 @@ public class UserRepository : IUserRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret)
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret);
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret,
BillingName, BillingAddress, BillingTaxId, BillingTaxType, ClientId, Phone)
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret,
@BillingName, @BillingAddress, @BillingTaxId, @BillingTaxType, @ClientId, @Phone);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, user);
}
@@ -75,7 +77,9 @@ public class UserRepository : IUserRepository
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin,
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret,
BillingName = @BillingName, BillingAddress = @BillingAddress, BillingTaxId = @BillingTaxId, BillingTaxType = @BillingTaxType,
ClientId = @ClientId, Phone = @Phone
WHERE Id = @Id";
await conn.ExecuteAsync(sql, user);
}

View File

@@ -46,7 +46,7 @@ public class AuthService : IAuthService
// Si MFA está activo, no devolver token aún, pedir verificación
if (user.IsMfaEnabled)
{
return new AuthResult { Success = true, RequiresMfa = true };
return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
}
// Éxito: Reiniciar intentos y generar token
@@ -59,7 +59,8 @@ public class AuthService : IAuthService
{
Success = true,
Token = _tokenService.GenerateToken(user),
RequiresPasswordChange = user.MustChangePassword
RequiresPasswordChange = user.MustChangePassword,
UserId = user.Id
};
}
@@ -83,7 +84,7 @@ public class AuthService : IAuthService
};
await _userRepo.CreateAsync(user);
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
}
// Login mediante Google OAuth
@@ -117,9 +118,9 @@ public class AuthService : IAuthService
await _userRepo.UpdateAsync(user);
}
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true };
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
}
catch (InvalidJwtException)
{

View File

@@ -1,6 +1,7 @@
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Interfaces;
using System.Text.RegularExpressions;
namespace SIGCM.Infrastructure.Services;
@@ -8,10 +9,12 @@ namespace SIGCM.Infrastructure.Services;
public class PricingService
{
private readonly PricingRepository _repo;
private readonly ICouponRepository _couponRepo;
public PricingService(PricingRepository repo)
public PricingService(PricingRepository repo, ICouponRepository couponRepo)
{
_repo = repo;
_couponRepo = couponRepo;
}
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
@@ -53,41 +56,45 @@ public class PricingService
currentCost += extraWordCost + specialCharCost;
// 4. Estilos (Negrita / Recuadro) - Se suman al precio unitario diario
// 4. Estilos (Negrita / Recuadro / Destacado)
if (request.IsBold) currentCost += pricing.BoldSurcharge;
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
// Costo Destacado (Hardcoded por ahora o agregar a regla)
decimal featuredSurcharge = 0;
if (request.IsFeatured)
{
featuredSurcharge = 500m; // Valor ejemplo por día
currentCost += featuredSurcharge;
}
// 5. Multiplicar por Días
decimal totalBeforeDiscount = currentCost * request.Days;
// 6. Motor de Promociones
// 6. Motor de Promociones y Cupones
var promotions = await _repo.GetActivePromotionsAsync();
decimal totalDiscount = 0;
List<string> appliedPromos = new();
// Lógica de promociones automáticas
foreach (var promo in promotions)
{
// Filtro por Categoría
if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue;
// Filtro por Cantidad de Días
if (promo.MinDays > 0 && request.Days < promo.MinDays) continue;
// Filtro por Días de la Semana
// Verificar si aplica según el día de la semana
if (!string.IsNullOrEmpty(promo.DaysOfWeek))
{
var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList();
bool hitsDay = false;
// Revisamos cada día que durará el aviso
for (int i = 0; i < request.Days; i++)
{
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
if (targetDays.Contains(currentDay)) hitsDay = true;
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
if (targetDays.Contains(currentDay)) hitsDay = true;
}
if (!hitsDay) continue; // No cae en ningún día de promo
if (!hitsDay) continue;
}
// Aplicar Descuento
if (promo.DiscountPercentage > 0)
{
decimal discountVal = totalBeforeDiscount * (promo.DiscountPercentage / 100m);
@@ -101,16 +108,38 @@ public class PricingService
}
}
// Lógica de Cupón
if (!string.IsNullOrEmpty(request.CouponCode))
{
var coupon = await _couponRepo.GetByCodeAsync(request.CouponCode);
if (coupon != null && (!coupon.ExpiryDate.HasValue || coupon.ExpiryDate > DateTime.UtcNow)
&& (!coupon.MaxUsages.HasValue || coupon.UsageCount < coupon.MaxUsages))
{
if (coupon.DiscountType == "Percentage")
{
decimal val = totalBeforeDiscount * (coupon.DiscountValue / 100m);
totalDiscount += val;
appliedPromos.Add($"CUPÓN {coupon.Code} (-{coupon.DiscountValue}%)");
}
else if (coupon.DiscountType == "Fixed")
{
totalDiscount += coupon.DiscountValue;
appliedPromos.Add($"CUPÓN {coupon.Code} (-${coupon.DiscountValue})");
}
}
}
return new CalculatePriceResponse
{
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
Discount = totalDiscount,
WordCount = realWordCount,
SpecialCharCount = specialCharCount,
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost}). {string.Join(", ", appliedPromos)}",
AppliedPromotion = string.Join(", ", appliedPromos)
};
}
}