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

View File

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

View File

@@ -5,21 +5,22 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
title: string; title: string;
children: React.ReactNode; 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; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <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"> <div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-semibold text-gray-800">{title}</h3> <h3 className="text-lg font-semibold text-gray-800">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-red-500"> <button onClick={onClose} className="text-gray-500 hover:text-red-500">
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<div className="p-4"> <div className="p-4 overflow-y-auto">
{children} {children}
</div> </div>
</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 { useAuthStore } from '../store/authStore';
import { import {
LogOut, LayoutDashboard, FolderTree, Users, LogOut, LayoutDashboard, FolderTree, Users,
FileText, DollarSign, Eye, History, User as ClientIcon, Search FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
} from 'lucide-react'; } from 'lucide-react';
import { useState, useEffect } from 'react';
import api from '../services/api';
export default function ProtectedLayout() { export default function ProtectedLayout() {
const { isAuthenticated, role, logout } = useAuthStore(); const { isAuthenticated, role, logout } = useAuthStore();
@@ -11,15 +13,43 @@ export default function ProtectedLayout() {
if (!isAuthenticated) return <Navigate to="/login" replace />; 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 // Definición de permisos por ruta
const menuItems = [ const menuItems = [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] }, { 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: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] }, { label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] }, { label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users 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: '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: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] }, { 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; if (!item.roles.includes(role || '')) return null;
return ( return (
<a <Link
key={item.href} key={item.href}
href={item.href} to={item.href}
className={`flex items-center gap-3 p-3 rounded-xl transition-all ${location.pathname === 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' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-gray-800 hover:text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`} }`}
> >
<div className="flex items-center gap-3">
{item.icon} {item.icon}
<span className="font-medium text-sm">{item.label}</span> <span className="font-medium text-sm">{item.label}</span>
</a> </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> </nav>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import api from '../../services/api'; 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 { interface AuditLog {
id: number; id: number;
@@ -8,58 +9,238 @@ interface AuditLog {
username: string; username: string;
createdAt: string; createdAt: string;
details: string; details: string;
userId: number;
} }
export default function AuditTimeline() { export default function AuditTimeline() {
const todayStr = new Date().toISOString().split('T')[0];
const [logs, setLogs] = useState<AuditLog[]>([]); const [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<{ id: number, username: string }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { // ESTADO DE FILTROS
api.get('/reports/audit').then(res => { const [filters, setFilters] = useState({
setLogs(res.data); from: todayStr,
setLoading(false); to: todayStr,
userId: ""
}); });
}, []);
const getIcon = (action: string) => { const loadInitialData = async () => {
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={18} />; try {
if (action === 'Rechazar') return <XCircle className="text-red-500" size={18} />; const res = await api.get('/users');
return <FileText className="text-blue-500" size={18} />; 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 ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="space-y-6 max-w-6xl mx-auto">
<div className="flex items-center gap-2"> {/* HEADER PREMIUM */}
<History className="text-blue-600" /> <div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<h2 className="text-2xl font-bold text-gray-800">Auditoría de Actividad</h2> <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>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden"> {/* BARRA DE FILTROS */}
<div className="p-4 bg-gray-50 border-b font-bold text-gray-600 text-sm"> <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">
Últimas acciones realizadas por el equipo <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>
<div className="divide-y"> <div className="flex flex-col md:flex-row gap-4 flex-[2]">
{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-1">
<div className="flex justify-between"> <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<span className="font-bold text-gray-800 flex items-center gap-1 italic"> <Calendar size={12} /> Desde
<UserIcon size={14} className="text-gray-400" /> {log.username} </label>
</span> <input
<span className="text-xs text-gray-400 flex items-center gap-1"> type="date"
<Clock size={12} /> {new Date(log.createdAt).toLocaleString()} 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="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> </span>
</div> </div>
<p className="text-sm text-gray-600 mt-1">{log.details}</p> </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> </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>
</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, Tooltip, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { dashboardService, type DashboardData } from '../services/dashboardService'; import { dashboardService, type DashboardData } from '../services/dashboardService';
import { translateStatus } from '../utils/translations';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import api from '../services/api'; import api from '../services/api';
import { reportService } from '../services/reportService'; import { reportService } from '../services/reportService';
@@ -330,7 +331,7 @@ export default function Dashboard() {
<td className="p-4"> <td className="p-4">
<span className={`flex items-center gap-1.5 font-bold ${t.status === 'Published' ? 'text-green-600' : 'text-orange-500'}`}> <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> <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> </span>
</td> </td>
</tr> </tr>

View File

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

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import api from '../../services/api'; 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 { interface Listing {
id: number; id: number;
@@ -14,18 +16,41 @@ interface Listing {
isBold: boolean; isBold: boolean;
isFrame: boolean; isFrame: boolean;
printDaysCount: number; printDaysCount: number;
unreadNotesCount: number;
categoryName?: string;
parentCategoryName?: string;
} }
export default function ModerationPage() { export default function ModerationPage() {
const [listings, setListings] = useState<Listing[]>([]); const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true); 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 () => { const loadPending = async () => {
setLoading(true); setLoading(true);
try { try {
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
const res = await api.get('/listings/pending'); const res = await api.get('/listings/pending');
setListings(res.data); setListings(res.data);
} catch { } catch {
@@ -34,24 +59,84 @@ export default function ModerationPage() {
finally { setLoading(false); } finally { setLoading(false); }
}; };
const handleAction = async (id: number, action: 'Published' | 'Rejected') => { const handleOpenDetail = async (id: number) => {
try { try {
await api.put(`/listings/${id}/status`, JSON.stringify(action), { const res = await api.get(`/listings/${id}`);
headers: { 'Content-Type': 'application/json' } 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)); setListings(listings.filter(l => l.id !== id));
setNoteModalOpen(false);
setDetailModalOpen(false);
} catch { } catch {
alert("Error al procesar el aviso"); 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>; if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;
return ( return (
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="mb-6"> <div className="mb-6 flex justify-between items-end">
<h2 className="text-2xl font-bold text-gray-800">Panel de Moderación</h2> <div>
<p className="text-gray-500">Revisión de avisos entrantes para Web y Diario Papel</p> <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> </div>
{listings.length === 0 ? ( {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> <p className="text-gray-500">No hay avisos pendientes de moderación en este momento.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-4">
{listings.map(listing => ( {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-4">
<div className="flex-1 p-6"> <div className="flex justify-between items-center mb-3">
<div className="flex justify-between items-start mb-4"> <div className="flex items-center gap-3">
<div> <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">
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded uppercase tracking-wider">
ID: #{listing.id} 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> )}
</span>
<h3 className="text-lg font-black text-gray-900 truncate max-w-md">{listing.title}</h3>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-lg font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p> <p className="text-sm font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
<p className="text-xs text-gray-400">{new Date(listing.createdAt).toLocaleString()}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Versión Web */} <div className="bg-gray-50 p-3 rounded-lg border border-gray-100 text-[13px] leading-relaxed">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100"> <div className="flex items-center gap-2 mb-1 text-gray-400 font-black text-[9px] uppercase tracking-widest">
<div className="flex items-center gap-2 mb-2 text-gray-700 font-bold text-sm"> <Globe size={12} /> Web / Mobile
<Globe size={16} /> VERSIÓN WEB / MOBILE
</div> </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> </div>
{/* Versión Impresa - CRÍTICO */} <div className={`p-3 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white text-[13px]`}>
<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-1">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-2 text-gray-400 font-black text-[9px] uppercase tracking-widest">
<div className="flex items-center gap-2 text-gray-700 font-bold text-sm"> <Printer size={12} /> Diario Papel
<Printer size={16} /> VERSIÓN IMPRESA
</div> </div>
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded font-bold"> <span className="text-[9px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-black uppercase">
{listing.printDaysCount} DÍAS {listing.printDaysCount} días
</span> </span>
</div> </div>
<div className={`text-sm p-2 bg-gray-50 rounded border border-dashed border-gray-300 ${listing.isBold ? 'font-bold' : ''}`}> <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 de impresión definido"} {listing.printText || "Sin texto..."}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Acciones Laterales */} <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="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]"> <div className="flex gap-2 md:flex-col w-full">
<button <button
onClick={() => handleAction(listing.id, 'Published')} onClick={() => handleOpenAction(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" 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={20} /> APROBAR <Check size={18} /> Aprobar
</button> </button>
<button <button
onClick={() => handleAction(listing.id, 'Rejected')} onClick={() => handleOpenAction(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" 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={20} /> RECHAZAR <X size={18} /> 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> </button>
</div> </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>
))} ))}
</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> </div>
); );
} }

View File

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

View File

@@ -18,6 +18,9 @@ export interface Listing {
viewCount: number; viewCount: number;
origin: string; origin: string;
overlayStatus?: string | null; overlayStatus?: string | null;
printDaysCount?: number;
publicationStartDate?: string;
approvedAt?: string;
} }
export interface ListingAttribute { 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); setLoading(true);
try { try {
// Enviamos el fondo inicial al endpoint que creamos en C# // 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"); showToast("Caja abierta correctamente. ¡Buen turno!", "success");
onSuccess(); onSuccess();
} catch (error) { } catch (error) {
@@ -47,10 +47,9 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} 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" 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="p-8 bg-slate-900 text-white relative">
<div className="absolute top-0 right-0 p-8 opacity-10"><Lock size={80} /></div> <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"> <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> <span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 block mb-2">Inicio de Jornada</span>
@@ -89,13 +88,6 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
</div> </div>
</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"> <div className="flex gap-4">
<button <button
type="button" type="button"
@@ -109,7 +101,7 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
disabled={loading} 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" 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> </button>
</div> </div>
</form> </form>

View File

@@ -3,7 +3,7 @@ import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom';
import { import {
LayoutDashboard, PlusCircle, Banknote, LogOut, LayoutDashboard, PlusCircle, Banknote, LogOut,
ChevronLeft, ChevronRight, Settings, Bell, ChevronLeft, ChevronRight, Settings, Bell,
Search, User as UserIcon, Monitor, User as UserIcon, Monitor,
TrendingUp, ClipboardList, ShieldCheck TrendingUp, ClipboardList, ShieldCheck
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
@@ -138,10 +138,7 @@ export default function CounterLayout() {
{/* TOP HEADER */} {/* 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"> <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="flex items-center gap-4 flex-1">
<div className="relative max-w-md w-full hidden md:block"> {/* Buscador eliminado por redundancia */}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input type="text" placeholder="Buscar avisos, clientes..." className="w-full pl-10 pr-4 py-2 bg-slate-100 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 transition-all outline-none font-medium" />
</div>
</div> </div>
<div className="flex items-center gap-4"> <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 { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { usePublicAuthStore } from './store/publicAuthStore'; import { usePublicAuthStore } from './store/publicAuthStore';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import api from './services/api';
// Páginas e Interfaz // Páginas e Interfaz
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@@ -43,12 +44,46 @@ const SocialIcons = {
function App() { function App() {
const { user, logout } = usePublicAuthStore(); const { user, logout } = usePublicAuthStore();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
setIsUserMenuOpen(false); 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 ( return (
<BrowserRouter> <BrowserRouter>
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]"> <div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
@@ -68,6 +103,21 @@ function App() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{user ? ( {user ? (
<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"
>
<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>
)}
</Link>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)} onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
@@ -106,6 +156,19 @@ function App() {
</div> </div>
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span> <span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
</Link> </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 <button
onClick={handleLogout} 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" 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"
@@ -120,6 +183,7 @@ function App() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </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"> <Link to="/login" className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-900 hover:text-blue-600 transition-all px-4">
<LogIn size={18} /> Entrar <LogIn size={18} /> Entrar

View File

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

View File

@@ -121,6 +121,15 @@ export default function ListingDetailPage() {
</span> </span>
</div> </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"> <h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
{listing.title} {listing.title}
</h1> </h1>
@@ -148,13 +157,13 @@ export default function ListingDetailPage() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{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"> <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} /> <MessageCircle size={18} />
Contactar Vendedor Contactar Vendedor
</button> </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 {/* Botón Ofrecer removido por solicitud */}
</button>
</div> </div>
{/* Stats integrados */} {/* Stats integrados */}

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { publicAuthService } from '../services/authService'; 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 { motion, AnimatePresence } from 'framer-motion';
import type { Listing } from '../types'; import type { Listing } from '../types';
import { import {
User, Package, Settings, ChevronRight, User, Package, Settings, ChevronRight,
Clock, Eye, ShieldCheck, QrCode, Lock, Clock, Eye, ShieldCheck, QrCode, Lock,
Bell, RefreshCcw Bell, RefreshCcw, Send, MessageSquare, X
} from 'lucide-react'; } from 'lucide-react';
import { usePublicAuthStore } from '../store/publicAuthStore'; import { usePublicAuthStore } from '../store/publicAuthStore';
import api from '../services/api'; import api from '../services/api';
@@ -19,7 +19,7 @@ interface MfaData {
secret: string; secret: string;
} }
type TabType = 'listings' | 'security' | 'settings'; type TabType = 'listings' | 'security' | 'settings' | 'mensajes';
export default function ProfilePage() { export default function ProfilePage() {
const { user, logout } = usePublicAuthStore(); const { user, logout } = usePublicAuthStore();
@@ -31,8 +31,88 @@ export default function ProfilePage() {
const baseUrl = import.meta.env.VITE_BASE_URL; const baseUrl = import.meta.env.VITE_BASE_URL;
const setRepublishData = useWizardStore(state => state.setRepublishData); const setRepublishData = useWizardStore(state => state.setRepublishData);
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null); const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
const [republishDetail, setRepublishDetail] = useState<any | null>(null);
const [loadingRepublish, setLoadingRepublish] = useState(false);
const navigate = useNavigate(); 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(() => { useEffect(() => {
if (!user) { if (!user) {
navigate('/login'); 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 () => { const handleSetupMfa = async () => {
try { try {
const data = await publicAuthService.setupMfa(); const data = await publicAuthService.setupMfa();
@@ -190,6 +283,13 @@ export default function ProfilePage() {
active={activeTab === 'security'} active={activeTab === 'security'}
onClick={() => setActiveTab('security')} onClick={() => setActiveTab('security')}
/> />
<SidebarItem
icon={<Bell size={18} />}
label="Mensajes"
active={activeTab === 'mensajes'}
onClick={() => setActiveTab('mensajes')}
badge={unreadCount}
/>
<SidebarItem <SidebarItem
icon={<Settings size={18} />} icon={<Settings size={18} />}
label="Ajustes" label="Ajustes"
@@ -250,20 +350,67 @@ export default function ProfilePage() {
<div className="text-right"> <div className="text-right">
<p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p> <p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p>
{/* BADGE DINÁMICO DE ESTADO */} {(() => {
<span className={clsx( const now = new Date();
"text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1", const startDate = item.publicationStartDate ? new Date(item.publicationStartDate) : (item.approvedAt ? new Date(item.approvedAt) : null);
// Estilos para Publicado const days = item.printDaysCount || 0;
item.status === 'Published' && "text-emerald-500 bg-emerald-50 border-emerald-100",
// Estilos para Pendiente de Moderación (Naranja) // Calcular fecha fin: Inicio + días
item.status === 'Pending' && "text-amber-500 bg-amber-50 border-amber-100", let endDate = null;
// Estilos para Borrador o Error (Gris) if (startDate && days > 0) {
(item.status === 'Draft' || !item.status) && "text-slate-400 bg-slate-50 border-slate-200" endDate = new Date(startDate);
)}> endDate.setDate(endDate.getDate() + days);
{item.status === 'Published' ? 'Publicado' : }
item.status === 'Pending' ? 'En Revisión' :
'Borrador'} 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> </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> </div>
</div> </div>
@@ -380,60 +527,360 @@ export default function ProfilePage() {
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<motion.div key="settings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}> <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> <h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Ajustes de cuenta</h2>
<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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled /> <InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
<InputGroup label="Email de contacto" value={user?.email || 'admin@sigcm.com'} /> <InputGroup
<InputGroup label="Teléfono / WhatsApp" placeholder="+54..." /> label="Email de contacto"
<InputGroup label="Ubicación" value="La Plata, Buenos Aires" /> value={billingData.email}
onChange={(e: any) => setBillingData({ ...billingData, email: e.target.value })}
/>
</div> </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"> <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 Guardar cambios
</button> </button>
</div> </div>
</motion.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> </AnimatePresence>
</div> </div>
</main> </main>
</div> </div>
</div > </div >
{/* MODAL DE CONFIRMACIÓN */} {/* MODAL DE CONFIRMACIÓN DE REPUBLIVACIÓN PREMIUM */}
<AnimatePresence> <AnimatePresence>
{republishTarget && ( {republishTarget && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div <motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={() => setRepublishTarget(null)} 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 <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 }} animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }} exit={{ scale: 0.95, 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" 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"> {/* HEADER TIPO PANEL */}
<RefreshCcw size={32} /> <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> </div>
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-3">Republicar Aviso</h3>
<p className="text-sm text-slate-500 font-medium mb-8 leading-relaxed">
Se creará una nueva publicación basada en <strong>{republishTarget.title}</strong>. Podrás editar los datos antes de realizar el pago.
</p>
<div className="flex gap-3">
<button <button
onClick={() => setRepublishTarget(null)} 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 <X size={20} />
</button> </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 <button
onClick={handleConfirmRepublish} 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" 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 Confirmar y Editar <ChevronRight size={18} />
</button> </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> </div>
</motion.div> </motion.div>
</div> </div>
@@ -444,30 +891,41 @@ export default function ProfilePage() {
} }
// COMPONENTES AUXILIARES PARA LIMPIEZA // 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 ( return (
<button <button
onClick={onClick} 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' ? 'bg-blue-600 text-white shadow-xl shadow-blue-200'
: 'text-slate-400 hover:text-slate-900 hover:bg-slate-50' : 'text-slate-400 hover:text-slate-900 hover:bg-slate-50'
}`} }`}
> >
<div className="flex items-center gap-3">
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}> <span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
{icon} {icon}
</span> </span>
{label} {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> </button>
); );
} }
function InputGroup({ label, value, disabled, placeholder }: any) { function InputGroup({ label, value, onChange, disabled, placeholder }: any) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label>
<input <input
type="text" type="text"
defaultValue={value} value={value}
onChange={onChange}
disabled={disabled} disabled={disabled}
placeholder={placeholder} 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' 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 { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWizardStore } from '../store/wizardStore'; import { useWizardStore } from '../store/wizardStore';
import CategorySelection from './Steps/CategorySelection'; import CategorySelection from './Steps/CategorySelection';
import OperationSelection from './Steps/OperationSelection'; import OperationSelection from './Steps/OperationSelection';
@@ -9,13 +10,24 @@ import SummaryStep from './Steps/SummaryStep';
import { wizardService } from '../services/wizardService'; import { wizardService } from '../services/wizardService';
import type { AttributeDefinition } from '../types'; import type { AttributeDefinition } from '../types';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import api from '../services/api';
import { User as UserIcon } from 'lucide-react';
export default function PublishPage() { export default function PublishPage() {
const { step, selectedCategory } = useWizardStore(); const { step, selectedCategory } = useWizardStore();
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]); 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(() => { useEffect(() => {
// CAMBIO: Agregamos el guard aquí también
if (selectedCategory?.id) { if (selectedCategory?.id) {
wizardService.getAttributes(selectedCategory.id) wizardService.getAttributes(selectedCategory.id)
.then(setDefinitions) .then(setDefinitions)
@@ -23,6 +35,34 @@ export default function PublishPage() {
} }
}, [selectedCategory?.id]); }, [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 ( return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20"> <div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
<SEO <SEO

View File

@@ -61,7 +61,11 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
isBold: false, isBold: false,
isFrame: false, isFrame: false,
printFontSize: 'normal', 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); const result = await wizardService.createListing(payload);

View File

@@ -13,27 +13,35 @@ interface PricingResult {
wordCount: number; wordCount: number;
specialCharCount: number; specialCharCount: number;
details: string; details: string;
discount: number;
} }
export default function TextEditorStep() { export default function TextEditorStep() {
const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore(); const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore();
const [text, setText] = useState(attributes['description'] || ''); const [text, setText] = useState(attributes['description'] || '');
const [days, setDays] = useState(parseInt(attributes['days'] as string) || 3); 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>({ const [pricing, setPricing] = useState<PricingResult>({
totalPrice: 0, totalPrice: 0,
baseCost: 0, baseCost: 0,
extraCost: 0, extraCost: 0,
wordCount: 0, wordCount: 0,
specialCharCount: 0, specialCharCount: 0,
details: '' details: '',
discount: 0
}); });
const [loadingPrice, setLoadingPrice] = useState(false); const [loadingPrice, setLoadingPrice] = useState(false);
const debouncedText = useDebounce(text, 800); const debouncedText = useDebounce(text, 800);
const debouncedCoupon = useDebounce(couponCode, 800);
useEffect(() => { useEffect(() => {
if (!selectedCategory || debouncedText.length === 0) { 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; return;
} }
@@ -44,9 +52,11 @@ export default function TextEditorStep() {
categoryId: selectedCategory.id, categoryId: selectedCategory.id,
text: debouncedText, text: debouncedText,
days: days, days: days,
isBold: false, isBold: false, // Could expose this too
isFrame: false, isFrame: false, // Could expose this too
startDate: new Date().toISOString() isFeatured: isFeatured,
startDate: startDate.toISOString(),
couponCode: debouncedCoupon
}); });
setPricing(response.data); setPricing(response.data);
} catch (error) { } catch (error) {
@@ -57,7 +67,7 @@ export default function TextEditorStep() {
}; };
calculatePrice(); calculatePrice();
}, [debouncedText, days, selectedCategory]); }, [debouncedText, days, selectedCategory, isFeatured, startDate, debouncedCoupon]);
const handleContinue = () => { const handleContinue = () => {
if (text.trim().length === 0) { if (text.trim().length === 0) {
@@ -67,6 +77,10 @@ export default function TextEditorStep() {
setAttribute('description', text); setAttribute('description', text);
setAttribute('days', days.toString()); setAttribute('days', days.toString());
setAttribute('adFee', pricing.totalPrice.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 setStep(5); // Siguiente paso
}; };
@@ -128,28 +142,88 @@ export default function TextEditorStep() {
</div> </div>
</section> </section>
{/* BLOQUE 2: CONFIGURACIÓN DE DÍAS */} {/* BLOQUE 2: CONFIGURACIÓN DE PUBLICACIÓN */}
<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"> <section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 space-y-8">
<div className="flex items-center gap-4">
<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"> <div className="p-3 bg-slate-900 rounded-2xl text-white">
<Calendar size={20} /> <Calendar size={20} />
</div> </div>
<div> <div>
<h4 className="font-black uppercase text-xs text-slate-900">Duración de la oferta</h4> <h4 className="font-black uppercase text-xs text-slate-900">Duración</h4>
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-wider">¿Cuántos días se publicará?</p> <div className="flex items-center bg-slate-100 rounded-xl p-1 mt-2">
</div> <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>
</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 <input
type="number" type="number"
value={days} value={days}
onChange={(e) => setDays(parseInt(e.target.value) || 1)} 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" className="w-12 text-center bg-transparent border-none outline-none font-black text-lg 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> <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>
{/* 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="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> </section>
{/* BLOQUE 3: PREVISUALIZACIÓN */} {/* BLOQUE 3: PREVISUALIZACIÓN */}

View File

@@ -14,6 +14,12 @@ export interface Listing {
images?: ListingImage[]; images?: ListingImage[];
viewCount: number; viewCount: number;
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null; overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
publicationStartDate?: string;
approvedAt?: string;
isFeatured?: boolean;
featuredExpiry?: string;
allowContact?: boolean;
printDaysCount?: number;
} }
export interface ListingAttribute { export interface ListingAttribute {
@@ -77,4 +83,8 @@ export interface CreateListingDto {
printFontSize?: string; printFontSize?: string;
printAlignment?: string; printAlignment?: string;
imagesToClone?: 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 Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs; using SIGCM.Application.DTOs;
using SIGCM.Application.Interfaces; using SIGCM.Application.Interfaces;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Entities;
namespace SIGCM.API.Controllers; namespace SIGCM.API.Controllers;
@@ -10,10 +12,12 @@ namespace SIGCM.API.Controllers;
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly AuditRepository _auditRepo;
public AuthController(IAuthService authService) public AuthController(IAuthService authService, AuditRepository auditRepo)
{ {
_authService = authService; _authService = authService;
_auditRepo = auditRepo;
} }
// Inicio de sesión tradicional // Inicio de sesión tradicional
@@ -22,6 +26,21 @@ public class AuthController : ControllerBase
{ {
var result = await _authService.LoginAsync(dto.Username, dto.Password); var result = await _authService.LoginAsync(dto.Username, dto.Password);
if (!result.Success) return Unauthorized(new { message = result.ErrorMessage }); 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); return Ok(result);
} }
@@ -63,6 +82,18 @@ public class AuthController : ControllerBase
if (!valid) return BadRequest(new { message = "Código inválido" }); if (!valid) return BadRequest(new { message = "Código inválido" });
await _authService.EnableMfaAsync(userId, true); 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 }); return Ok(new { success = true });
} }
} }

View File

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

View File

@@ -53,6 +53,22 @@ public class CategoriesController : ControllerBase
var id = await _repository.AddAsync(category); var id = await _repository.AddAsync(category);
category.Id = id; 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); return CreatedAtAction(nameof(GetById), new { id }, category);
} }
@@ -71,6 +87,22 @@ public class CategoriesController : ControllerBase
} }
await _repository.UpdateAsync(category); 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(); return NoContent();
} }
@@ -127,6 +159,22 @@ public class CategoriesController : ControllerBase
{ {
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales."); if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId); 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." }); return Ok(new { message = "Fusión completada." });
} }
@@ -143,6 +191,22 @@ public class CategoriesController : ControllerBase
} }
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId); 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." }); return Ok(new { message = "Avisos movidos correctamente." });
} }

View File

@@ -10,10 +10,12 @@ namespace SIGCM.API.Controllers;
public class ClientsController : ControllerBase public class ClientsController : ControllerBase
{ {
private readonly ClientRepository _repo; private readonly ClientRepository _repo;
private readonly AuditRepository _auditRepo;
public ClientsController(ClientRepository repo) public ClientsController(ClientRepository repo, AuditRepository auditRepo)
{ {
_repo = repo; _repo = repo;
_auditRepo = auditRepo;
} }
[HttpGet("search")] [HttpGet("search")]
@@ -36,6 +38,22 @@ public class ClientsController : ControllerBase
{ {
if (id != client.Id) return BadRequest("ID de URL no coincide con el cuerpo"); if (id != client.Id) return BadRequest("ID de URL no coincide con el cuerpo");
await _repo.UpdateAsync(client); 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(); 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 IListingRepository _repository;
private readonly ClientRepository _clientRepo; private readonly ClientRepository _clientRepo;
private readonly IUserRepository _userRepo;
private readonly AuditRepository _auditRepo; 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; _repository = repository;
_clientRepo = clientRepo; _clientRepo = clientRepo;
_userRepo = userRepo;
_auditRepo = auditRepo; _auditRepo = auditRepo;
_couponRepo = couponRepo;
} }
[HttpPost] [HttpPost]
@@ -37,12 +41,29 @@ public class ListingsController : ControllerBase
} }
int? clientId = null; int? clientId = null;
var user = await _userRepo.GetByIdAsync(currentUserId);
// Lógica de Cliente (asegurar existencia en BD) // Lógica de Vinculación Usuario-Cliente
if (!string.IsNullOrWhiteSpace(dto.ClientDni)) if (user != null)
{ {
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName; clientId = user.ClientId;
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
// 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 var listing = new Listing
@@ -56,19 +77,43 @@ public class ListingsController : ControllerBase
Currency = "ARS", Currency = "ARS",
UserId = currentUserId, UserId = currentUserId,
ClientId = clientId, ClientId = clientId,
Origin = dto.Origin, Origin = dto.Origin ?? "Web",
Status = dto.Status, Status = dto.Status,
ImagesToClone = dto.ImagesToClone, ImagesToClone = dto.ImagesToClone,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
PrintText = dto.PrintText, PrintText = dto.PrintText,
PrintDaysCount = dto.PrintDaysCount, PrintDaysCount = dto.PrintDaysCount,
PrintStartDate = dto.PrintStartDate, PrintStartDate = dto.PrintStartDate ?? dto.PublicationStartDate,
IsBold = dto.IsBold, IsBold = dto.IsBold,
IsFrame = dto.IsFrame, IsFrame = dto.IsFrame,
PrintFontSize = dto.PrintFontSize, 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 // Conversión de Pagos
List<Payment>? payments = null; List<Payment>? payments = null;
if (dto.Payments != null && dto.Payments.Any()) if (dto.Payments != null && dto.Payments.Any())
@@ -85,6 +130,16 @@ public class ListingsController : ControllerBase
var id = await _repository.CreateAsync(listing, dto.Attributes, payments); 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 // Registro de Auditoría
await _auditRepo.AddLogAsync(new AuditLog await _auditRepo.AddLogAsync(new AuditLog
{ {
@@ -92,7 +147,7 @@ public class ListingsController : ControllerBase
Action = "CREATE_LISTING", Action = "CREATE_LISTING",
EntityId = id, EntityId = id,
EntityType = "Listing", 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 CreatedAt = DateTime.UtcNow
}); });
@@ -144,7 +199,8 @@ public class ListingsController : ControllerBase
request.From, request.From,
request.To, request.To,
request.Origin, request.Origin,
request.Status request.Status,
request.OnlyActive
); );
return Ok(results); return Ok(results);
} }
@@ -159,28 +215,41 @@ public class ListingsController : ControllerBase
[HttpPut("{id}/status")] [HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Moderador")] [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) // Obtener el ID de quien realiza la acción (Administrador/Moderador)
var userIdStr = User.FindFirst("Id")?.Value; var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId))
return Unauthorized(); 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 await _auditRepo.AddLogAsync(new AuditLog
{ {
UserId = currentUserId, UserId = currentUserId,
Action = status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING", Action = request.Status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
EntityId = id, EntityId = id,
EntityType = "Listing", EntityType = "Listing",
Details = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {status}", Details = actionDetails,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
return Ok(); return Ok();
} }
public class UpdateStatusRequest
{
public required string Status { get; set; }
public string? Note { get; set; }
}
public class SearchRequest public class SearchRequest
{ {
public string? Query { get; set; } public string? Query { get; set; }
@@ -190,6 +259,7 @@ public class ListingsController : ControllerBase
public DateTime? To { get; set; } public DateTime? To { get; set; }
public string? Origin { get; set; } public string? Origin { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public bool OnlyActive { get; set; } = false;
} }
[HttpGet("pending/count")] [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.Entities;
using SIGCM.Domain.Interfaces; using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Services; using SIGCM.Infrastructure.Services;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers; namespace SIGCM.API.Controllers;
@@ -13,11 +14,13 @@ public class PaymentsController : ControllerBase
{ {
private readonly MercadoPagoService _mpService; private readonly MercadoPagoService _mpService;
private readonly IListingRepository _listingRepo; private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo) public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo, AuditRepository auditRepo)
{ {
_mpService = mpService; _mpService = mpService;
_listingRepo = listingRepo; _listingRepo = listingRepo;
_auditRepo = auditRepo;
} }
/* /*
@@ -73,6 +76,22 @@ public class PaymentsController : ControllerBase
}; };
await _listingRepo.AddPaymentAsync(paymentRecord); 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." }); 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")] [HttpGet("audit")]
[Authorize(Roles = "Admin")] [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 start = from ?? DateTime.UtcNow.Date;
var logs = await _auditRepo.GetRecentLogsAsync(100); var end = to ?? DateTime.UtcNow.AddDays(1).Date.AddSeconds(-1);
var logs = await _auditRepo.GetFilteredLogsAsync(start, end, userId);
return Ok(logs); 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.Application.DTOs;
using SIGCM.Domain.Entities; using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces; using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers; namespace SIGCM.API.Controllers;
@@ -12,18 +13,22 @@ namespace SIGCM.API.Controllers;
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly IUserRepository _repository; private readonly IUserRepository _repository;
private readonly AuditRepository _auditRepo;
public UsersController(IUserRepository repository) public UsersController(IUserRepository repository, AuditRepository auditRepo)
{ {
_repository = repository; _repository = repository;
_auditRepo = auditRepo;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll() public async Task<IActionResult> GetAll()
{ {
var users = await _repository.GetAllAsync(); var users = await _repository.GetAllAsync();
// Don't expose password hashes // Excluimos clientes para que solo aparezcan en su propio gestor
var sanitized = users.Select(u => new { var sanitized = users
.Where(u => u.Role != "Client")
.Select(u => new {
u.Id, u.Username, u.Role, u.Email, u.CreatedAt u.Id, u.Username, u.Role, u.Email, u.CreatedAt
}); });
return Ok(sanitized); return Ok(sanitized);
@@ -57,6 +62,22 @@ public class UsersController : ControllerBase
}; };
var id = await _repository.CreateAsync(user); 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 }); return CreatedAtAction(nameof(GetById), new { id }, new { id, user.Username });
} }
@@ -76,6 +97,22 @@ public class UsersController : ControllerBase
} }
await _repository.UpdateAsync(user); 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(); return NoContent();
} }
@@ -85,6 +122,22 @@ public class UsersController : ControllerBase
// Safe check: prevent deleting yourself optional but good practice // Safe check: prevent deleting yourself optional but good practice
// For now simple delete // For now simple delete
await _repository.DeleteAsync(id); 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(); 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 IsLockedOut { get; set; }
public bool RequiresPasswordChange { get; set; } public bool RequiresPasswordChange { get; set; }
public bool RequiresMfa { 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 string? Notes { get; set; }
} }
public class CashOpeningDto
{
public decimal OpeningBalance { get; set; }
}
// DTO de respuesta con el resultado del cierre // DTO de respuesta con el resultado del cierre
public class CashClosingResultDto public class CashClosingResultDto
{ {

View File

@@ -27,6 +27,12 @@ public class CreateListingDto
public string PrintFontSize { get; set; } = "normal"; public string PrintFontSize { get; set; } = "normal";
public string PrintAlignment { get; set; } = "left"; 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 // Atributos Dinámicos
public Dictionary<int, string> Attributes { get; set; } = new(); public Dictionary<int, string> Attributes { get; set; } = new();

View File

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

View File

@@ -15,3 +15,12 @@ public class UpdateUserDto
public required string Role { get; set; } public required string Role { get; set; }
public string? Email { 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 // Propiedades auxiliares
public string? CategoryName { get; set; } public string? CategoryName { get; set; }
public string? ParentCategoryName { get; set; }
public string? MainImageUrl { 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 string? GoogleId { get; set; }
public bool IsMfaEnabled { get; set; } public bool IsMfaEnabled { get; set; }
public string? MfaSecret { 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? from = null,
DateTime? to = null, DateTime? to = null,
string? origin = null, string? origin = null,
string? status = null string? status = null,
bool onlyActive = false
); );
// Impresión // Impresión

View File

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

View File

@@ -116,9 +116,181 @@ BEGIN
); );
END END
"; ";
// Ejecutar creación de tablas // Ejecutar creación de tablas base
await connection.ExecuteAsync(schemaSql); 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) --- // --- SEED DE DATOS (Usuario Admin) ---
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = '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<ImageOptimizationService>();
services.AddScoped<IClaimRepository, ClaimRepository>(); services.AddScoped<IClaimRepository, ClaimRepository>();
services.AddScoped<NotificationRepository>(); services.AddScoped<NotificationRepository>();
services.AddScoped<ICouponRepository, CouponRepository>();
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars) // Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp => services.AddScoped<MercadoPagoService>(sp =>

View File

@@ -37,4 +37,19 @@ public class AuditRepository
ORDER BY a.CreatedAt DESC"; ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit }); 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; _db = db;
} }
// Búsqueda inteligente con protección de nulos // Búsqueda inteligente redireccionada a Users
public async Task<IEnumerable<Client>> SearchAsync(string query) public async Task<IEnumerable<Client>> SearchAsync(string query)
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
SELECT TOP 10 SELECT TOP 10
Id, Id,
ISNULL(Name, 'Sin Nombre') as Name, ISNULL(BillingName, Username) as Name,
ISNULL(DniOrCuit, '') as DniOrCuit, ISNULL(BillingTaxId, '') as DniOrCuit,
Email, Phone, Address Email, Phone, BillingAddress as Address
FROM Clients FROM Users
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query
ORDER BY Name"; ORDER BY BillingName";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" }); 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) public async Task<int> EnsureClientExistsAsync(string name, string dni)
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var existingId = await conn.ExecuteScalarAsync<int?>( 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) 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; return existingId.Value;
} }
else else
{ {
// Si no existe, creamos un usuario con rol Cliente (sin password por ahora, es solo para gestión de mostrador)
var sql = @" 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);"; 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() public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
SELECT SELECT
c.Id as id, u.Id as id,
ISNULL(c.Name, 'Sin Nombre') as name, ISNULL(u.BillingName, u.Username) as name,
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit, ISNULL(u.BillingTaxId, 'S/D') as dniOrCuit,
ISNULL(c.Email, 'Sin correo') as email, ISNULL(u.Email, 'Sin correo') as email,
ISNULL(c.Phone, 'Sin teléfono') as phone, ISNULL(u.Phone, 'Sin teléfono') as phone,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds, (SELECT COUNT(1) FROM Listings l WHERE l.ClientId = u.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = u.Id), 0) as totalSpent
FROM Clients c FROM Users u
ORDER BY c.Name"; WHERE Role IN ('Client', 'User') -- Mostramos tanto clientes puros como usuarios web
ORDER BY name";
return await conn.QueryAsync(sql); return await conn.QueryAsync(sql);
} }
@@ -73,12 +77,12 @@ public class ClientRepository
{ {
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
UPDATE Clients UPDATE Users
SET Name = @Name, SET BillingName = @Name,
DniOrCuit = @DniOrCuit, BillingTaxId = @DniOrCuit,
Email = @Email, Email = @Email,
Phone = @Phone, Phone = @Phone,
Address = @Address BillingAddress = @Address
WHERE Id = @Id"; WHERE Id = @Id";
await conn.ExecuteAsync(sql, client); await conn.ExecuteAsync(sql, client);
} }
@@ -88,21 +92,23 @@ public class ClientRepository
using var conn = _db.CreateConnection(); using var conn = _db.CreateConnection();
var sql = @" var sql = @"
SELECT SELECT
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address, u.Id,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds, ISNULL(u.BillingName, u.Username) as Name,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested, u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate, (SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id) as TotalAds,
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds, 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(( ISNULL((
SELECT TOP 1 cat.Name SELECT TOP 1 cat.Name
FROM Listings l FROM Listings l
JOIN Categories cat ON l.CategoryId = cat.Id JOIN Categories cat ON l.CategoryId = cat.Id
WHERE l.ClientId = c.Id WHERE l.ClientId = u.Id
GROUP BY cat.Name GROUP BY cat.Name
ORDER BY COUNT(l.Id) DESC ORDER BY COUNT(l.Id) DESC
), 'N/A') as PreferredCategory ), 'N/A') as PreferredCategory
FROM Clients c FROM Users u
WHERE c.Id = @Id"; WHERE u.Id = @Id";
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId }); 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 ( INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency, CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount, 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 ( VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency, @CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount, @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);"; SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -101,11 +103,15 @@ public class ListingRepository : IListingRepository
using var conn = _connectionFactory.CreateConnection(); using var conn = _connectionFactory.CreateConnection();
var sql = @" var sql = @"
SELECT 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 u.Username as CashierName
FROM Listings l FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id 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 LEFT JOIN Users u ON l.UserId = u.Id
WHERE l.Id = @Id; WHERE l.Id = @Id;
@@ -142,10 +148,16 @@ public class ListingRepository : IListingRepository
CreatedAt = listingData.CreatedAt, CreatedAt = listingData.CreatedAt,
UserId = listingData.UserId, UserId = listingData.UserId,
CategoryName = listingData.CategoryName, CategoryName = listingData.CategoryName,
ParentCategoryName = listingData.ParentCategoryName,
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0), PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
PrintText = listingData.PrintText, PrintText = listingData.PrintText,
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold), IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame), 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, Attributes = attributes,
Images = images, Images = images,
@@ -162,6 +174,8 @@ public class ListingRepository : IListingRepository
FROM Listings l FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id JOIN Categories c ON l.CategoryId = c.Id
WHERE l.Status = 'Published' 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"; ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql); return await conn.QueryAsync<Listing>(sql);
@@ -190,7 +204,7 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId) 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 // Búsqueda Avanzada Facetada
@@ -201,23 +215,24 @@ public class ListingRepository : IListingRepository
DateTime? from = null, DateTime? from = null,
DateTime? to = null, DateTime? to = null,
string? origin = null, string? origin = null,
string? status = null) string? status = null,
bool onlyActive = false)
{ {
using var conn = _connectionFactory.CreateConnection(); using var conn = _connectionFactory.CreateConnection();
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
string sql = @" 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 (SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id 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"; WHERE 1=1";
// --- FILTROS EXISTENTES --- // --- FILTROS EXISTENTES ---
if (!string.IsNullOrEmpty(query)) 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("Query", $"%{query}%");
parameters.Add("ExactQuery", query); parameters.Add("ExactQuery", query);
} }
@@ -253,7 +268,16 @@ public class ListingRepository : IListingRepository
parameters.Add("Status", status); 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); return await conn.QueryAsync<Listing>(sql, parameters);
} }
@@ -277,15 +301,28 @@ public class ListingRepository : IListingRepository
{ {
using var conn = _connectionFactory.CreateConnection(); using var conn = _connectionFactory.CreateConnection();
// Avisos que NO están publicados ni rechazados ni borrados. // Avisos que NO están publicados ni rechazados ni borrados.
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión. // Asumimos 'Pending'. Incluimos conteo de notas no leídas del usuario.
// Para este ejemplo, buscamos 'Pending'. var sql = @"
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC"); 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) public async Task UpdateStatusAsync(int id, string status)
{ {
using var conn = _connectionFactory.CreateConnection(); 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) public async Task<int> CountByCategoryIdAsync(int categoryId)
@@ -562,10 +599,11 @@ public class ListingRepository : IListingRepository
SELECT SELECT
l.Id, l.CreatedAt as Date, l.Title, l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount, 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 FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.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 WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published' AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId) AND (@UserId IS NULL OR l.UserId = @UserId)

View File

@@ -46,8 +46,10 @@ public class UserRepository : IUserRepository
{ {
using var conn = _connectionFactory.CreateConnection(); using var conn = _connectionFactory.CreateConnection();
var sql = @" var sql = @"
INSERT INTO Users (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,
VALUES (@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);"; SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, user); return await conn.QuerySingleAsync<int>(sql, user);
} }
@@ -75,7 +77,9 @@ public class UserRepository : IUserRepository
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash, SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd, FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin, 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"; WHERE Id = @Id";
await conn.ExecuteAsync(sql, user); 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 // Si MFA está activo, no devolver token aún, pedir verificación
if (user.IsMfaEnabled) 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 // Éxito: Reiniciar intentos y generar token
@@ -59,7 +59,8 @@ public class AuthService : IAuthService
{ {
Success = true, Success = true,
Token = _tokenService.GenerateToken(user), 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); 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 // Login mediante Google OAuth
@@ -117,9 +118,9 @@ public class AuthService : IAuthService
await _userRepo.UpdateAsync(user); 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) catch (InvalidJwtException)
{ {

View File

@@ -1,6 +1,7 @@
using SIGCM.Application.DTOs; using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities; using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories; using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Interfaces;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace SIGCM.Infrastructure.Services; namespace SIGCM.Infrastructure.Services;
@@ -8,10 +9,12 @@ namespace SIGCM.Infrastructure.Services;
public class PricingService public class PricingService
{ {
private readonly PricingRepository _repo; private readonly PricingRepository _repo;
private readonly ICouponRepository _couponRepo;
public PricingService(PricingRepository repo) public PricingService(PricingRepository repo, ICouponRepository couponRepo)
{ {
_repo = repo; _repo = repo;
_couponRepo = couponRepo;
} }
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request) public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
@@ -53,41 +56,45 @@ public class PricingService
currentCost += extraWordCost + specialCharCost; 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.IsBold) currentCost += pricing.BoldSurcharge;
if (request.IsFrame) currentCost += pricing.FrameSurcharge; 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 // 5. Multiplicar por Días
decimal totalBeforeDiscount = currentCost * request.Days; decimal totalBeforeDiscount = currentCost * request.Days;
// 6. Motor de Promociones // 6. Motor de Promociones y Cupones
var promotions = await _repo.GetActivePromotionsAsync(); var promotions = await _repo.GetActivePromotionsAsync();
decimal totalDiscount = 0; decimal totalDiscount = 0;
List<string> appliedPromos = new(); List<string> appliedPromos = new();
// Lógica de promociones automáticas
foreach (var promo in promotions) foreach (var promo in promotions)
{ {
// Filtro por Categoría
if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue; if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue;
// Filtro por Cantidad de Días
if (promo.MinDays > 0 && request.Days < promo.MinDays) continue; 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)) if (!string.IsNullOrEmpty(promo.DaysOfWeek))
{ {
var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList(); var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList();
bool hitsDay = false; bool hitsDay = false;
// Revisamos cada día que durará el aviso
for (int i = 0; i < request.Days; i++) for (int i = 0; i < request.Days; i++)
{ {
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek; var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
if (targetDays.Contains(currentDay)) hitsDay = true; 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) if (promo.DiscountPercentage > 0)
{ {
decimal discountVal = totalBeforeDiscount * (promo.DiscountPercentage / 100m); 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 return new CalculatePriceResponse
{ {
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount), TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice * request.Days, BaseCost = pricing.BasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * 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, Discount = totalDiscount,
WordCount = realWordCount, WordCount = realWordCount,
SpecialCharCount = specialCharCount, 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)
}; };
} }
} }