Feat Varios 3
This commit is contained in:
@@ -12,6 +12,7 @@ import ModerationPage from './pages/Moderation/ModerationPage';
|
||||
import ListingExplorer from './pages/Listings/ListingExplorer';
|
||||
import AuditTimeline from './pages/Audit/AuditTimeline';
|
||||
import ClientManager from './pages/Clients/ClientManager';
|
||||
import CouponsPage from './pages/Coupons/CouponsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -29,6 +30,7 @@ function App() {
|
||||
<Route path="/diagram" element={<DiagramPage />} />
|
||||
<Route path="/pricing" element={<PricingManager />} />
|
||||
<Route path="/promotions" element={<PromotionsManager />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
<Route path="/reports/categories" element={<SalesByCategory />} />
|
||||
<Route path="/audit" element={<AuditTimeline />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Printer, Globe, Tag, Image as ImageIcon, Info, User } from 'lucide-react';
|
||||
import { translateStatus } from '../../utils/translations';
|
||||
|
||||
interface ListingDetail {
|
||||
listing: {
|
||||
@@ -10,6 +11,8 @@ interface ListingDetail {
|
||||
adFee: number;
|
||||
price: number;
|
||||
status: string;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
printText?: string;
|
||||
printDaysCount?: number;
|
||||
isBold?: boolean;
|
||||
@@ -23,11 +26,13 @@ interface ListingDetail {
|
||||
export default function ListingDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
detail
|
||||
detail,
|
||||
children
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
detail: ListingDetail | null
|
||||
detail: ListingDetail | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
if (!isOpen || !detail) return null;
|
||||
const { listing, attributes, images } = detail;
|
||||
@@ -124,8 +129,31 @@ export default function ListingDetailModal({
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Estado</span>
|
||||
<span className="font-bold uppercase text-xs tracking-widest">{listing.status}</span>
|
||||
<span className="font-bold uppercase text-[10px] text-right leading-tight">
|
||||
{(() => {
|
||||
if (listing.status === 'Published' && listing.publicationStartDate) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const start = new Date(listing.publicationStartDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
if (start > today) return "APROBADO";
|
||||
}
|
||||
return translateStatus(listing.status);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{listing.publicationStartDate && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Fecha Inicio</span>
|
||||
<span className="font-bold text-xs">{new Date(listing.publicationStartDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.approvedAt && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Aprobación</span>
|
||||
<span className="font-bold text-xs">{new Date(listing.approvedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +190,13 @@ export default function ListingDetailModal({
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 italic">"{listing.description || 'Sin descripción adicional.'}"</p>
|
||||
</div>
|
||||
|
||||
{/* ACCIONES EXTRA (EJ: MODERACIÓN) */}
|
||||
{children && (
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,22 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md animate-fade-in">
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${maxWidth} max-h-[90vh] flex flex-col animate-fade-in`}>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-red-500">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
LogOut, LayoutDashboard, FolderTree, Users,
|
||||
FileText, DollarSign, Eye, History, User as ClientIcon, Search
|
||||
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
const { isAuthenticated, role, logout } = useAuthStore();
|
||||
@@ -11,15 +13,43 @@ export default function ProtectedLayout() {
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && (role === 'Admin' || role === 'Moderador')) {
|
||||
const loadCount = async () => {
|
||||
try {
|
||||
const res = await api.get('/moderationstats/unread-count');
|
||||
setUnreadCount(res.data);
|
||||
} catch { /* silence */ }
|
||||
};
|
||||
|
||||
const handleUpdate = (e: any) => {
|
||||
if (e.detail !== undefined) setUnreadCount(e.detail);
|
||||
else loadCount();
|
||||
};
|
||||
|
||||
loadCount();
|
||||
window.addEventListener('moderation-unread-changed', handleUpdate);
|
||||
const interval = setInterval(loadCount, 60000);
|
||||
return () => {
|
||||
window.removeEventListener('moderation-unread-changed', handleUpdate);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [isAuthenticated, role]);
|
||||
|
||||
// Definición de permisos por ruta
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'] },
|
||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
||||
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
||||
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Promociones', href: '/promotions', icon: <Tag size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Cupones', href: '/coupons', icon: <Tag size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
|
||||
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] },
|
||||
];
|
||||
@@ -37,17 +67,24 @@ export default function ProtectedLayout() {
|
||||
if (!item.roles.includes(role || '')) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl transition-all ${location.pathname === item.href
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
to={item.href}
|
||||
className={`flex items-center justify-between p-3 rounded-xl transition-all ${location.pathname === item.href
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.icon}
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</div>
|
||||
{(item as any).badge > 0 && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black ${location.pathname === item.href ? 'bg-white text-blue-600' : 'bg-rose-500 text-white'}`}>
|
||||
{(item as any).badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Clock } from 'lucide-react';
|
||||
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Search, Calendar, Users, AlertCircle, Info } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditLog {
|
||||
id: number;
|
||||
@@ -8,58 +9,238 @@ interface AuditLog {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
details: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export default function AuditTimeline() {
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [users, setUsers] = useState<{ id: number, username: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/reports/audit').then(res => {
|
||||
setLogs(res.data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
// ESTADO DE FILTROS
|
||||
const [filters, setFilters] = useState({
|
||||
from: todayStr,
|
||||
to: todayStr,
|
||||
userId: ""
|
||||
});
|
||||
|
||||
const getIcon = (action: string) => {
|
||||
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={18} />;
|
||||
if (action === 'Rechazar') return <XCircle className="text-red-500" size={18} />;
|
||||
return <FileText className="text-blue-500" size={18} />;
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const res = await api.get('/users');
|
||||
setUsers(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/reports/audit', {
|
||||
params: {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
userId: filters.userId || null
|
||||
}
|
||||
});
|
||||
setLogs(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadInitialData(); }, []);
|
||||
useEffect(() => { loadLogs(); }, [loadLogs]);
|
||||
|
||||
const translateDetails = (details: string) => {
|
||||
if (!details) return details;
|
||||
return details
|
||||
.replace(/Published/g, 'Publicado')
|
||||
.replace(/Pending/g, 'Pendiente')
|
||||
.replace(/Rejected/g, 'Rechazado')
|
||||
.replace(/Draft/g, 'Borrador')
|
||||
.replace(/True/g, 'Sí')
|
||||
.replace(/False/g, 'No')
|
||||
.replace(/Total:/g, 'Total:')
|
||||
.replace(/Featured:/g, 'Destacado:')
|
||||
.replace(/El usuario (\d+) cambió el estado del aviso #(\d+) a/g, 'Se cambió el estado del aviso #$2 a')
|
||||
.replace(/Aviso creado por usuario autenticado/g, 'Nuevo aviso creado');
|
||||
};
|
||||
|
||||
const getLogMeta = (action: string) => {
|
||||
switch (action) {
|
||||
case 'APPROVE_LISTING':
|
||||
case 'Published':
|
||||
case 'Aprobar':
|
||||
return { icon: <CheckCircle className="text-emerald-500" size={16} />, color: 'bg-emerald-50 text-emerald-700 border-emerald-100', label: 'Aprobación' };
|
||||
|
||||
case 'REJECT_LISTING':
|
||||
case 'Rejected':
|
||||
case 'Rechazar':
|
||||
return { icon: <XCircle className="text-rose-500" size={16} />, color: 'bg-rose-50 text-rose-700 border-rose-100', label: 'Rechazo' };
|
||||
|
||||
case 'CREATE_LISTING':
|
||||
case 'CREATE_USER':
|
||||
case 'CREATE_COUPON':
|
||||
case 'CREATE_CATEGORY':
|
||||
return { icon: <FileText className="text-blue-500" size={16} />, color: 'bg-blue-50 text-blue-700 border-blue-100', label: 'Creación' };
|
||||
|
||||
case 'UPDATE_USER':
|
||||
case 'UPDATE_CLIENT':
|
||||
case 'UPDATE_CATEGORY':
|
||||
case 'SAVE_PRICING':
|
||||
case 'UPDATE_PROMOTION':
|
||||
return { icon: <Search className="text-amber-500" size={16} />, color: 'bg-amber-50 text-amber-700 border-amber-100', label: 'Actualización' };
|
||||
|
||||
case 'DELETE_USER':
|
||||
case 'DELETE_COUPON':
|
||||
case 'DELETE_CATEGORY':
|
||||
case 'DELETE_PROMOTION':
|
||||
return { icon: <XCircle className="text-slate-600" size={16} />, color: 'bg-slate-100 text-slate-700 border-slate-200', label: 'Eliminación' };
|
||||
|
||||
case 'LOGIN':
|
||||
return { icon: <Users className="text-indigo-500" size={16} />, color: 'bg-indigo-50 text-indigo-700 border-indigo-100', label: 'Acceso' };
|
||||
|
||||
case 'CONFIRM_PAYMENT':
|
||||
return { icon: <CheckCircle className="text-cyan-500" size={16} />, color: 'bg-cyan-50 text-cyan-700 border-cyan-100', label: 'Pago' };
|
||||
|
||||
default:
|
||||
return { icon: <Info className="text-slate-400" size={16} />, color: 'bg-slate-50 text-slate-500 border-slate-200', label: action.replace('_', ' ') };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Auditoría de Actividad</h2>
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
{/* HEADER PREMIUM */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
|
||||
<History size={32} className="text-blue-600" /> Auditoría de Sistema
|
||||
</h2>
|
||||
<p className="text-slate-500 font-medium text-sm mt-1">Trazabilidad completa de acciones y cambios de estado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-gray-50 border-b font-bold text-gray-600 text-sm">
|
||||
Últimas acciones realizadas por el equipo
|
||||
{/* BARRA DE FILTROS */}
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-xl shadow-slate-200/50 flex flex-wrap items-end gap-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Users size={12} /> Usuario Responsable
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2.5 font-bold text-sm outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
value={filters.userId}
|
||||
onChange={e => setFilters({ ...filters, userId: e.target.value })}
|
||||
>
|
||||
<option value="">TODOS LOS USUARIOS</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-[2]">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Calendar size={12} /> Desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
||||
value={filters.from}
|
||||
onChange={e => setFilters({ ...filters, from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Calendar size={12} /> Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
||||
value={filters.to}
|
||||
onChange={e => setFilters({ ...filters, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadLogs}
|
||||
className="bg-slate-900 text-white p-3 rounded-xl hover:bg-black transition-all shadow-lg shadow-slate-200"
|
||||
>
|
||||
<Search size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LISTA DE EVENTOS TIPO TIMELINE */}
|
||||
<div className="relative">
|
||||
{/* Línea vertical de fondo para el timeline */}
|
||||
<div className="absolute left-12 top-0 bottom-0 w-0.5 bg-slate-100 hidden md:block"></div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center text-gray-400 italic">Cargando historial...</div>
|
||||
) : logs.map(log => (
|
||||
<div key={log.id} className="p-4 hover:bg-gray-50 transition flex items-start gap-4">
|
||||
<div className="mt-1">{getIcon(log.action)}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold text-gray-800 flex items-center gap-1 italic">
|
||||
<UserIcon size={14} className="text-gray-400" /> {log.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} /> {new Date(log.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{log.details}</p>
|
||||
</div>
|
||||
<div className="py-20 text-center flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-slate-100 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Sincronizando logs...</p>
|
||||
</div>
|
||||
))}
|
||||
) : logs.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border border-dashed border-slate-200 p-20 text-center text-slate-400">
|
||||
<AlertCircle size={40} className="mx-auto mb-4 opacity-20" />
|
||||
<p className="font-bold uppercase text-xs tracking-widest">No hay eventos para el período seleccionado</p>
|
||||
</div>
|
||||
) : logs.map((log, idx) => {
|
||||
const meta = getLogMeta(log.action);
|
||||
return (
|
||||
<div key={log.id} className="relative group animate-fade-in" style={{ animationDelay: `${idx * 50}ms` }}>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
|
||||
{/* HORA Y PUNTO (Timeline) */}
|
||||
<div className="hidden md:flex flex-col items-end w-24 flex-shrink-0">
|
||||
<span className="text-xs font-black text-slate-900">{new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{new Date(log.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* ICONO CENTRAL BUBBLE */}
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center border-4 border-white shadow-sm z-10 transition-transform group-hover:scale-110",
|
||||
meta.color.split(' ')[0]
|
||||
)}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
|
||||
{/* CARD DE CONTENIDO */}
|
||||
<div className="flex-1 bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm group-hover:shadow-md group-hover:border-blue-100 transition-all">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-widest mb-0.5">{meta.label}</span>
|
||||
<span className="text-sm font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
|
||||
<UserIcon size={14} className="text-slate-300" /> {log.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-slate-50 rounded-full text-[9px] font-black text-slate-400 uppercase tracking-wider border border-slate-100">
|
||||
#{log.id}
|
||||
</span>
|
||||
<div className="md:hidden text-[10px] font-bold text-slate-400">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-slate-600 leading-relaxed bg-slate-50/50 p-3 rounded-xl border border-slate-50 italic">
|
||||
{translateDetails(log.details)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
205
frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx
Normal file
205
frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { dashboardService, type DashboardData } from '../services/dashboardService';
|
||||
import { translateStatus } from '../utils/translations';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import api from '../services/api';
|
||||
import { reportService } from '../services/reportService';
|
||||
@@ -330,7 +331,7 @@ export default function Dashboard() {
|
||||
<td className="p-4">
|
||||
<span className={`flex items-center gap-1.5 font-bold ${t.status === 'Published' ? 'text-green-600' : 'text-orange-500'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${t.status === 'Published' ? 'bg-green-600' : 'bg-orange-500'}`}></div>
|
||||
{t.status}
|
||||
{translateStatus(t.status)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
X, Monitor, Globe, Download
|
||||
} from 'lucide-react';
|
||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||
import { translateStatus } from '../../utils/translations';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function ListingExplorer() {
|
||||
@@ -19,14 +20,15 @@ export default function ListingExplorer() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDetail, setSelectedDetail] = useState<ListingDetail | null>(null);
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
// ESTADO DE FILTROS
|
||||
const [filters, setFilters] = useState({
|
||||
query: searchParams.get('q') || "",
|
||||
categoryId: "",
|
||||
origin: "All",
|
||||
status: "",
|
||||
from: "",
|
||||
to: ""
|
||||
from: todayStr,
|
||||
to: todayStr
|
||||
});
|
||||
|
||||
const loadInitialData = async () => {
|
||||
@@ -35,6 +37,7 @@ export default function ListingExplorer() {
|
||||
};
|
||||
|
||||
const loadListings = useCallback(async () => {
|
||||
if (!filters.from || !filters.to) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/listings/search', {
|
||||
@@ -42,8 +45,8 @@ export default function ListingExplorer() {
|
||||
categoryId: filters.categoryId ? parseInt(filters.categoryId) : null,
|
||||
origin: filters.origin,
|
||||
status: filters.status,
|
||||
from: filters.from || null,
|
||||
to: filters.to || null
|
||||
from: filters.from,
|
||||
to: filters.to
|
||||
});
|
||||
setListings(res.data);
|
||||
} finally {
|
||||
@@ -61,7 +64,7 @@ export default function ListingExplorer() {
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: "", to: "" });
|
||||
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: todayStr, to: todayStr });
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
@@ -231,14 +234,63 @@ export default function ListingExplorer() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-5 text-center">
|
||||
<span className={clsx(
|
||||
"px-2 py-1 rounded-full text-[9px] font-black uppercase border",
|
||||
l.status === 'Published' ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
|
||||
l.status === 'Pending' ? "bg-amber-50 text-amber-700 border-amber-200" :
|
||||
"bg-slate-50 text-slate-500 border-slate-200"
|
||||
)}>
|
||||
{l.status}
|
||||
</span>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = l.publicationStartDate ? new Date(l.publicationStartDate) : (l.approvedAt ? new Date(l.approvedAt) : null);
|
||||
const days = l.printDaysCount || 0;
|
||||
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
|
||||
if (l.status === 'Pending') {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-amber-50 text-amber-700 border-amber-200">
|
||||
Pendiente
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (l.status === 'Rejected') {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-rose-50 text-rose-700 border-rose-200">
|
||||
Rechazado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (l.status === 'Published') {
|
||||
if (endDate && now > endDate) {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-100 text-slate-500 border-slate-300">
|
||||
Finalizado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (startDate && now < startDate) {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-blue-50 text-blue-700 border-blue-200">
|
||||
Aprobado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
Publicado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-50 text-slate-500 border-slate-200">
|
||||
{translateStatus(l.status)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="p-5 text-right">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Check, X, Printer, Globe, MessageSquare } from 'lucide-react';
|
||||
import { Check, X, Printer, Globe, Eye, MessageSquare } from 'lucide-react';
|
||||
import Modal from '../../components/Modal';
|
||||
import ListingDetailModal from '../../components/Listings/ListingDetailModal';
|
||||
|
||||
interface Listing {
|
||||
id: number;
|
||||
@@ -14,18 +16,41 @@ interface Listing {
|
||||
isBold: boolean;
|
||||
isFrame: boolean;
|
||||
printDaysCount: number;
|
||||
unreadNotesCount: number;
|
||||
categoryName?: string;
|
||||
parentCategoryName?: string;
|
||||
}
|
||||
|
||||
export default function ModerationPage() {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadPending(); }, []);
|
||||
// Estados para los Modales
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedListingDetail, setSelectedListingDetail] = useState<any>(null);
|
||||
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [actionListingId, setActionListingId] = useState<number | null>(null);
|
||||
const [actionType, setActionType] = useState<'Published' | 'Rejected' | null>(null);
|
||||
const [threadNotes, setThreadNotes] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPending();
|
||||
loadUnreadCount();
|
||||
}, []);
|
||||
|
||||
const loadUnreadCount = async () => {
|
||||
try {
|
||||
const res = await api.get('/moderationstats/unread-count');
|
||||
// Notificar al sidebar
|
||||
window.dispatchEvent(new CustomEvent('moderation-unread-changed', { detail: res.data }));
|
||||
} catch { /* silence */ }
|
||||
};
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
|
||||
const res = await api.get('/listings/pending');
|
||||
setListings(res.data);
|
||||
} catch {
|
||||
@@ -34,24 +59,84 @@ export default function ModerationPage() {
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleAction = async (id: number, action: 'Published' | 'Rejected') => {
|
||||
const handleOpenDetail = async (id: number) => {
|
||||
try {
|
||||
await api.put(`/listings/${id}/status`, JSON.stringify(action), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const res = await api.get(`/listings/${id}`);
|
||||
setSelectedListingDetail(res.data);
|
||||
setDetailModalOpen(true);
|
||||
} catch {
|
||||
alert("Error al cargar detalles del aviso");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAction = (id: number, type: 'Published' | 'Rejected') => {
|
||||
setActionListingId(id);
|
||||
setActionType(type);
|
||||
setNoteText('');
|
||||
|
||||
if (type === 'Rejected') {
|
||||
setNoteModalOpen(true);
|
||||
} else {
|
||||
confirmAction(id, type);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAction = async (id: number, type: 'Published' | 'Rejected', note?: string) => {
|
||||
try {
|
||||
const payload = { status: type, note };
|
||||
await api.put(`/listings/${id}/status`, payload);
|
||||
|
||||
setListings(listings.filter(l => l.id !== id));
|
||||
setNoteModalOpen(false);
|
||||
setDetailModalOpen(false);
|
||||
} catch {
|
||||
alert("Error al procesar el aviso");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitNote = async () => {
|
||||
if (!actionListingId) return;
|
||||
|
||||
try {
|
||||
if (actionType) {
|
||||
confirmAction(actionListingId, actionType, noteText);
|
||||
} else {
|
||||
await api.post(`/listings/${actionListingId}/notes`, { message: noteText });
|
||||
setNoteModalOpen(false);
|
||||
setNoteText('');
|
||||
loadUnreadCount();
|
||||
alert('Nota enviada al usuario correctamente');
|
||||
}
|
||||
} catch {
|
||||
alert('Error al enviar la nota');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenNoteModal = async (id: number, type: 'Published' | 'Rejected' | null) => {
|
||||
setActionListingId(id);
|
||||
setActionType(type);
|
||||
setNoteText('');
|
||||
setNoteModalOpen(true);
|
||||
|
||||
try {
|
||||
const res = await api.get(`/listings/${id}/notes`);
|
||||
setThreadNotes(res.data);
|
||||
loadUnreadCount();
|
||||
setListings(prev => prev.map(l => l.id === id ? { ...l, unreadNotesCount: 0 } : l));
|
||||
} catch {
|
||||
setThreadNotes([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Panel de Moderación</h2>
|
||||
<p className="text-gray-500">Revisión de avisos entrantes para Web y Diario Papel</p>
|
||||
<div className="mb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-gray-900 tracking-tighter uppercase leading-none mb-2">Panel de Moderación</h2>
|
||||
<p className="text-gray-500 font-medium">Revisión de avisos entrantes para Web y Diario Papel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listings.length === 0 ? (
|
||||
@@ -61,74 +146,159 @@ export default function ModerationPage() {
|
||||
<p className="text-gray-500">No hay avisos pendientes de moderación en este momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{listings.map(listing => (
|
||||
<div key={listing.id} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col md:flex-row">
|
||||
<div key={listing.id} className={`bg-white rounded-xl shadow-sm border ${listing.unreadNotesCount > 0 ? 'border-blue-400 ring-1 ring-blue-100' : 'border-gray-200'} overflow-hidden flex flex-col md:flex-row relative group`}>
|
||||
|
||||
{/* Info del Aviso */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded uppercase tracking-wider">
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded uppercase tracking-wider border border-blue-100 flex items-center gap-1.5">
|
||||
ID: #{listing.id}
|
||||
{listing.unreadNotesCount > 0 && (
|
||||
<span className="flex items-center gap-1 bg-blue-600 text-white px-1.5 py-0.5 rounded-md animate-pulse">
|
||||
<MessageSquare size={8} fill="white" />
|
||||
{listing.unreadNotesCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold text-gray-900 mt-1">{listing.title}</h3>
|
||||
<h3 className="text-lg font-black text-gray-900 truncate max-w-md">{listing.title}</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-400">{new Date(listing.createdAt).toLocaleString()}</p>
|
||||
<p className="text-sm font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Versión Web */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-700 font-bold text-sm">
|
||||
<Globe size={16} /> VERSIÓN WEB / MOBILE
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100 text-[13px] leading-relaxed">
|
||||
<div className="flex items-center gap-2 mb-1 text-gray-400 font-black text-[9px] uppercase tracking-widest">
|
||||
<Globe size={12} /> Web / Mobile
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 italic line-clamp-3">"{listing.description}"</p>
|
||||
<p className="text-gray-600 italic line-clamp-2">"{listing.description}"</p>
|
||||
</div>
|
||||
|
||||
{/* Versión Impresa - CRÍTICO */}
|
||||
<div className={`p-4 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-gray-700 font-bold text-sm">
|
||||
<Printer size={16} /> VERSIÓN IMPRESA
|
||||
<div className={`p-3 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white text-[13px]`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 text-gray-400 font-black text-[9px] uppercase tracking-widest">
|
||||
<Printer size={12} /> Diario Papel
|
||||
</div>
|
||||
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded font-bold">
|
||||
{listing.printDaysCount} DÍAS
|
||||
<span className="text-[9px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-black uppercase">
|
||||
{listing.printDaysCount} días
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm p-2 bg-gray-50 rounded border border-dashed border-gray-300 ${listing.isBold ? 'font-bold' : ''}`}>
|
||||
{listing.printText || "Sin texto de impresión definido"}
|
||||
<div className={`p-1 bg-gray-50 rounded border border-dashed border-gray-200 line-clamp-2 ${listing.isBold ? 'font-bold' : ''}`}>
|
||||
{listing.printText || "Sin texto..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acciones Laterales */}
|
||||
<div className="bg-gray-50 border-t md:border-t-0 md:border-l border-gray-200 p-6 flex flex-row md:flex-col gap-3 justify-center min-w-[200px]">
|
||||
<button
|
||||
onClick={() => handleAction(listing.id, 'Published')}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition shadow-sm"
|
||||
>
|
||||
<Check size={20} /> APROBAR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(listing.id, 'Rejected')}
|
||||
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<X size={20} /> RECHAZAR
|
||||
</button>
|
||||
<button className="p-3 text-gray-400 hover:text-gray-600 flex items-center justify-center gap-2 text-xs font-medium">
|
||||
<MessageSquare size={16} /> Enviar Nota
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-50/50 border-t md:border-t-0 md:border-l border-gray-200 p-4 flex flex-row md:flex-col gap-2 justify-center min-w-[180px]">
|
||||
<div className="flex gap-2 md:flex-col w-full">
|
||||
<button
|
||||
onClick={() => handleOpenAction(listing.id, 'Published')}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition shadow-sm shadow-green-100"
|
||||
>
|
||||
<Check size={18} /> Aprobar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenAction(listing.id, 'Rejected')}
|
||||
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<X size={18} /> Rechazar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full mt-1">
|
||||
<button
|
||||
onClick={() => handleOpenNoteModal(listing.id, null)}
|
||||
className={`flex-1 py-2 rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition border ${listing.unreadNotesCount > 0
|
||||
? 'bg-blue-600 text-white border-blue-700 shadow-md shadow-blue-100'
|
||||
: 'text-slate-600 hover:bg-slate-100 border-transparent'}`}
|
||||
>
|
||||
<MessageSquare size={14} fill={listing.unreadNotesCount > 0 ? "white" : "none"} />
|
||||
{listing.unreadNotesCount > 0 ? 'Responder' : 'Nota'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenDetail(listing.id)}
|
||||
className="flex-1 py-2 text-blue-600 hover:bg-blue-50 border border-transparent rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition"
|
||||
>
|
||||
<Eye size={14} /> Detalle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Detalle Premium Reutilizado */}
|
||||
<ListingDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
detail={selectedListingDetail}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Published')}
|
||||
className="w-full py-4 bg-green-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-lg shadow-green-100 hover:bg-green-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check size={18} /> Aprobar aviso
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Rejected')}
|
||||
className="w-full py-3 bg-white text-red-600 border border-red-100 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-red-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<X size={16} /> Rechazar aviso
|
||||
</button>
|
||||
</div>
|
||||
</ListingDetailModal>
|
||||
|
||||
{/* Modal de Nota / Rechazo */}
|
||||
<Modal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} title={actionType === 'Rejected' ? 'Rechazar Aviso' : actionType === 'Published' ? 'Aprobar con Nota' : 'Chat con el Usuario'}>
|
||||
<div className="space-y-4">
|
||||
{threadNotes.length > 0 && (
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100 max-h-64 overflow-y-auto space-y-3 mb-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Historial de mensajes</p>
|
||||
{threadNotes.map((n: any) => (
|
||||
<div key={n.id} className={`flex flex-col ${n.isFromModerator ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`max-w-[80%] p-3 rounded-2xl text-sm ${n.isFromModerator ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-700 rounded-tl-none'}`}>
|
||||
{n.message}
|
||||
</div>
|
||||
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">
|
||||
{n.isFromModerator ? 'Moderador' : 'Usuario'} • {new Date(n.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
{actionType === 'Rejected'
|
||||
? 'Por favor indica la razón del rechazo para notificar al usuario.'
|
||||
: actionType === 'Published'
|
||||
? 'Opcionalmente, agrega una nota al aprobar este aviso.'
|
||||
: 'Escribe un mensaje al usuario. Recibirá una notificación en su panel.'}
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full border rounded-2xl p-4 text-sm h-32 focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="Escribe tu mensaje aquí..."
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setNoteModalOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl font-bold transition-all">Cancelar</button>
|
||||
<button
|
||||
onClick={handleSubmitNote}
|
||||
className={`px-6 py-2.5 rounded-xl text-white font-black uppercase text-xs tracking-widest transition-all ${actionType === 'Rejected' ? 'bg-red-600 hover:bg-red-700 shadow-lg shadow-red-200' : 'bg-blue-600 hover:bg-blue-700 shadow-lg shadow-blue-200'}`}
|
||||
>
|
||||
{actionType === 'Rejected' ? 'Confirmar Rechazo' : actionType === 'Published' ? 'Aprobar' : 'Enviar Mensaje'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ export default function UserManager() {
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Cajero">Cajero</option>
|
||||
<option value="Usuario">Usuario</option>
|
||||
<option value="Moderador">Moderador</option>
|
||||
<option value="Diagramador">Diagramador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface Listing {
|
||||
viewCount: number;
|
||||
origin: string;
|
||||
overlayStatus?: string | null;
|
||||
printDaysCount?: number;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListingAttribute {
|
||||
|
||||
16
frontend/admin-panel/src/utils/formatters.ts
Normal file
16
frontend/admin-panel/src/utils/formatters.ts
Normal 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);
|
||||
};
|
||||
22
frontend/admin-panel/src/utils/translations.ts
Normal file
22
frontend/admin-panel/src/utils/translations.ts
Normal 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;
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Enviamos el fondo inicial al endpoint que creamos en C#
|
||||
await api.post('/cashsessions/open', openingBalance);
|
||||
await api.post('/cashsessions/open', { openingBalance });
|
||||
showToast("Caja abierta correctamente. ¡Buen turno!", "success");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
@@ -47,11 +47,10 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
|
||||
initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white w-full max-w-md rounded-[2.5rem] shadow-2xl overflow-hidden border border-white/20"
|
||||
>
|
||||
{/* Header (Igual al anterior) */}
|
||||
{/* Header */}
|
||||
<div className="p-8 bg-slate-900 text-white relative">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10"><Lock size={80} /></div>
|
||||
{/* Botón X de cerrar en el header */}
|
||||
|
||||
|
||||
<div className="relative z-10">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 block mb-2">Inicio de Jornada</span>
|
||||
<h2 className="text-3xl font-black tracking-tight uppercase">Apertura de Caja</h2>
|
||||
@@ -89,13 +88,6 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
<Play size={18} fill="currentColor" /> {loading ? 'Procesando...' : 'Iniciar Sesión de Trabajo'}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -109,7 +101,7 @@ export default function CashOpeningModal({ onSuccess, onCancel }: Props) {
|
||||
disabled={loading}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
<Play size={18} fill="currentColor" /> {loading ? 'Cargando...' : 'Iniciar Turno'}
|
||||
<Play size={18} fill="currentColor" /> {loading ? 'Abriendo...' : 'Iniciar Turno'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, PlusCircle, Banknote, LogOut,
|
||||
ChevronLeft, ChevronRight, Settings, Bell,
|
||||
Search, User as UserIcon, Monitor,
|
||||
User as UserIcon, Monitor,
|
||||
TrendingUp, ClipboardList, ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -138,10 +138,7 @@ export default function CounterLayout() {
|
||||
{/* TOP HEADER */}
|
||||
<header className="h-16 bg-white/80 backdrop-blur-md border-b border-slate-200 px-8 flex items-center justify-between z-20 sticky top-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative max-w-md w-full hidden md:block">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input type="text" placeholder="Buscar avisos, clientes..." className="w-full pl-10 pr-4 py-2 bg-slate-100 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 transition-all outline-none font-medium" />
|
||||
</div>
|
||||
{/* Buscador eliminado por redundancia */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||
import { usePublicAuthStore } from './store/publicAuthStore';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import api from './services/api';
|
||||
|
||||
// Páginas e Interfaz
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -43,12 +44,46 @@ const SocialIcons = {
|
||||
function App() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setIsUserMenuOpen(false);
|
||||
};
|
||||
|
||||
// Cargar conteo de mensajes no leídos
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const loadUnread = async () => {
|
||||
try {
|
||||
const res = await api.get('/usernotes/unread-count');
|
||||
setUnreadCount(res.data);
|
||||
} catch (e) {
|
||||
console.error("Error cargando notificaciones", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnreadChange = (e: any) => {
|
||||
if (e.detail !== undefined) {
|
||||
setUnreadCount(e.detail);
|
||||
} else {
|
||||
loadUnread();
|
||||
}
|
||||
};
|
||||
|
||||
loadUnread();
|
||||
window.addEventListener('unread-count-changed', handleUnreadChange);
|
||||
|
||||
// Polling cada 60 segundos por si acaso
|
||||
const interval = setInterval(loadUnread, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unread-count-changed', handleUnreadChange);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
|
||||
@@ -68,57 +103,86 @@ function App() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
|
||||
<div className="flex items-center gap-3">
|
||||
{/* CAMPANA DE NOTIFICACIONES */}
|
||||
<Link
|
||||
to="/profile?tab=mensajes"
|
||||
className="relative p-3 bg-slate-50 rounded-2xl border border-slate-100 text-slate-400 hover:text-blue-600 hover:bg-white transition-all group"
|
||||
title="Ver mensajes"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<UserIcon size={16} />
|
||||
</div>
|
||||
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-slate-50 mb-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
|
||||
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<UserIcon size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
<Mail size={20} className="group-hover:scale-110 transition-transform" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-2 right-2 flex h-5 w-5 items-center justify-center rounded-full bg-rose-500 text-[10px] font-black text-white ring-4 ring-white shadow-lg shadow-rose-200 animate-bounce">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<UserIcon size={16} />
|
||||
</div>
|
||||
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-slate-50 mb-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
|
||||
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<UserIcon size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
|
||||
</Link>
|
||||
|
||||
{/* Acceso a mensajes desde el menú movil o dropdown */}
|
||||
<Link
|
||||
to="/profile?tab=mensajes"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex md:hidden items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<Mail size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mensajes</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-900 hover:text-blue-600 transition-all px-4">
|
||||
|
||||
@@ -46,7 +46,8 @@ export default function HomePage() {
|
||||
const response = await api.post('/listings/search', {
|
||||
query: searchText,
|
||||
categoryId: selectedCatId,
|
||||
attributes: dynamicFilters
|
||||
attributes: dynamicFilters,
|
||||
onlyActive: true
|
||||
});
|
||||
setListings(response.data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -121,6 +121,15 @@ export default function ListingDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Featured Badge */}
|
||||
{listing.isFeatured && (
|
||||
<div className="mb-6 flex">
|
||||
<span className="bg-amber-400 text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-wider shadow-md shadow-amber-200">
|
||||
★ Destacado
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
|
||||
{listing.title}
|
||||
</h1>
|
||||
@@ -148,13 +157,13 @@ export default function ListingDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
|
||||
<MessageCircle size={18} />
|
||||
Contactar Vendedor
|
||||
</button>
|
||||
<button className="w-full py-4 bg-slate-900 hover:bg-black text-white rounded-2xl font-black uppercase text-[11px] tracking-widest transition-all active:scale-95 shadow-xl shadow-slate-200">
|
||||
Realizar Oferta
|
||||
</button>
|
||||
{listing.allowContact !== false && (
|
||||
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
|
||||
<MessageCircle size={18} />
|
||||
Contactar Vendedor
|
||||
</button>
|
||||
)}
|
||||
{/* Botón Ofrecer removido por solicitud */}
|
||||
</div>
|
||||
|
||||
{/* Stats integrados */}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { publicAuthService } from '../services/authService';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { Listing } from '../types';
|
||||
import {
|
||||
User, Package, Settings, ChevronRight,
|
||||
Clock, Eye, ShieldCheck, QrCode, Lock,
|
||||
Bell, RefreshCcw
|
||||
Bell, RefreshCcw, Send, MessageSquare, X
|
||||
} from 'lucide-react';
|
||||
import { usePublicAuthStore } from '../store/publicAuthStore';
|
||||
import api from '../services/api';
|
||||
@@ -19,7 +19,7 @@ interface MfaData {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
type TabType = 'listings' | 'security' | 'settings';
|
||||
type TabType = 'listings' | 'security' | 'settings' | 'mensajes';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
@@ -31,8 +31,88 @@ export default function ProfilePage() {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
const setRepublishData = useWizardStore(state => state.setRepublishData);
|
||||
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
|
||||
const [republishDetail, setRepublishDetail] = useState<any | null>(null);
|
||||
const [loadingRepublish, setLoadingRepublish] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [billingData, setBillingData] = useState({
|
||||
billingName: '',
|
||||
billingAddress: '',
|
||||
billingTaxId: '',
|
||||
billingTaxType: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
const [notes, setNotes] = useState<any[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [selectedThreadListingId, setSelectedThreadListingId] = useState<number | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab === 'mensajes') setActiveTab('mensajes');
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'settings') {
|
||||
api.get('/profile').then(res => {
|
||||
setBillingData({
|
||||
billingName: res.data.billingName || '',
|
||||
billingAddress: res.data.billingAddress || '',
|
||||
billingTaxId: res.data.billingTaxId || '',
|
||||
billingTaxType: res.data.billingTaxType || '',
|
||||
email: res.data.email || ''
|
||||
});
|
||||
}).catch(console.error);
|
||||
}
|
||||
if (activeTab === 'mensajes') {
|
||||
loadMessages();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const res = await api.get('/usernotes/my-notes');
|
||||
setNotes(res.data);
|
||||
// Actualizar conteo de no leídos
|
||||
const unreadRes = await api.get('/usernotes/unread-count');
|
||||
setUnreadCount(unreadRes.data);
|
||||
|
||||
// Notificar al header (App.tsx)
|
||||
window.dispatchEvent(new CustomEvent('unread-count-changed', { detail: unreadRes.data }));
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleReply = async (listingId: number) => {
|
||||
if (!replyText.trim()) return;
|
||||
try {
|
||||
await api.post(`/usernotes/listing/${listingId}/reply`, { message: replyText });
|
||||
setReplyText('');
|
||||
loadMessages();
|
||||
} catch {
|
||||
alert("Error al enviar respuesta");
|
||||
}
|
||||
};
|
||||
|
||||
const markThreadAsRead = async (listingId: number) => {
|
||||
// El backend lo hace automáticamente cuando pides el historial por aviso (en una implementación real ideal)
|
||||
// Pero aquí lo hacemos manual o confiamos en el endpoint GetByListing que marcaba como leídos.
|
||||
try {
|
||||
await api.get(`/usernotes/listing/${listingId}`);
|
||||
loadMessages();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await api.put('/profile', billingData);
|
||||
alert('Ajustes guardados correctamente.');
|
||||
} catch (error) {
|
||||
alert('Error al guardar ajustes.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
@@ -70,6 +150,19 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Carga de detalle para el modal de republicación
|
||||
useEffect(() => {
|
||||
if (republishTarget) {
|
||||
setLoadingRepublish(true);
|
||||
api.get(`/listings/${republishTarget.id}`)
|
||||
.then(res => setRepublishDetail(res.data))
|
||||
.catch(() => alert("Error al cargar detalles del aviso"))
|
||||
.finally(() => setLoadingRepublish(false));
|
||||
} else {
|
||||
setRepublishDetail(null);
|
||||
}
|
||||
}, [republishTarget]);
|
||||
|
||||
const handleSetupMfa = async () => {
|
||||
try {
|
||||
const data = await publicAuthService.setupMfa();
|
||||
@@ -190,6 +283,13 @@ export default function ProfilePage() {
|
||||
active={activeTab === 'security'}
|
||||
onClick={() => setActiveTab('security')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Bell size={18} />}
|
||||
label="Mensajes"
|
||||
active={activeTab === 'mensajes'}
|
||||
onClick={() => setActiveTab('mensajes')}
|
||||
badge={unreadCount}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Settings size={18} />}
|
||||
label="Ajustes"
|
||||
@@ -250,20 +350,67 @@ export default function ProfilePage() {
|
||||
<div className="text-right">
|
||||
<p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p>
|
||||
|
||||
{/* BADGE DINÁMICO DE ESTADO */}
|
||||
<span className={clsx(
|
||||
"text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1",
|
||||
// Estilos para Publicado
|
||||
item.status === 'Published' && "text-emerald-500 bg-emerald-50 border-emerald-100",
|
||||
// Estilos para Pendiente de Moderación (Naranja)
|
||||
item.status === 'Pending' && "text-amber-500 bg-amber-50 border-amber-100",
|
||||
// Estilos para Borrador o Error (Gris)
|
||||
(item.status === 'Draft' || !item.status) && "text-slate-400 bg-slate-50 border-slate-200"
|
||||
)}>
|
||||
{item.status === 'Published' ? 'Publicado' :
|
||||
item.status === 'Pending' ? 'En Revisión' :
|
||||
'Borrador'}
|
||||
</span>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = item.publicationStartDate ? new Date(item.publicationStartDate) : (item.approvedAt ? new Date(item.approvedAt) : null);
|
||||
const days = item.printDaysCount || 0;
|
||||
|
||||
// Calcular fecha fin: Inicio + días
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
|
||||
if (item.status === 'Pending') {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-amber-500 bg-amber-50 border-amber-100">
|
||||
En Revisión
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.status === 'Rejected') {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-rose-500 bg-rose-50 border-rose-100">
|
||||
Rechazado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.status === 'Published') {
|
||||
// Si ya terminó el plazo
|
||||
if (endDate && now > endDate) {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
|
||||
Finalizado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Si está aprobado pero falta para que empiece
|
||||
if (startDate && now < startDate) {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-blue-500 bg-blue-50 border-blue-100">
|
||||
Aprobado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Por defecto: Publicado (Activo)
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-emerald-500 bg-emerald-50 border-emerald-100">
|
||||
Publicado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
|
||||
Borrador
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,61 +527,361 @@ export default function ProfilePage() {
|
||||
{activeTab === 'settings' && (
|
||||
<motion.div key="settings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Ajustes de cuenta</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
|
||||
<InputGroup label="Email de contacto" value={user?.email || 'admin@sigcm.com'} />
|
||||
<InputGroup label="Teléfono / WhatsApp" placeholder="+54..." />
|
||||
<InputGroup label="Ubicación" value="La Plata, Buenos Aires" />
|
||||
|
||||
<div className="bg-blue-50/50 p-6 rounded-3xl mb-8 border border-blue-100">
|
||||
<h3 className="text-sm font-black text-blue-900 uppercase mb-4">Datos de Contacto</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
|
||||
<InputGroup
|
||||
label="Email de contacto"
|
||||
value={billingData.email}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-3xl mb-8 border border-slate-100">
|
||||
<h3 className="text-sm font-black text-slate-900 uppercase mb-4 flex gap-2 items-center">
|
||||
<Settings size={16} className="text-slate-400" /> Datos de Facturación
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup
|
||||
label="Razón Social / Nombre"
|
||||
placeholder="Ej: Juan Perez"
|
||||
value={billingData.billingName}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingName: e.target.value })}
|
||||
/>
|
||||
<InputGroup
|
||||
label="CUIT / DNI"
|
||||
placeholder="20-12345678-9"
|
||||
value={billingData.billingTaxId}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingTaxId: e.target.value })}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Condición Fiscal</label>
|
||||
<select
|
||||
value={billingData.billingTaxType}
|
||||
onChange={(e) => setBillingData({ ...billingData, billingTaxType: e.target.value })}
|
||||
className="w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-700 focus:ring-4 focus:ring-blue-100 transition-all outline-none appearance-none"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="Consumidor Final">Consumidor Final</option>
|
||||
<option value="Responsable Inscripto">Responsable Inscripto</option>
|
||||
<option value="Monotributista">Monotributista</option>
|
||||
<option value="Exento">Exento</option>
|
||||
</select>
|
||||
</div>
|
||||
<InputGroup
|
||||
label="Domicilio Fiscal"
|
||||
placeholder="Calle 123, Ciudad"
|
||||
value={billingData.billingAddress}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingAddress: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-10 border-t border-slate-50 flex justify-end">
|
||||
<button className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all">
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mensajes' && (
|
||||
<motion.div key="mensajes" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">Mensajes de Moderación</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-rose-500 text-white px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest animate-pulse">
|
||||
{unreadCount} Nuevos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-30">
|
||||
<MessageSquare size={64} className="mb-4" />
|
||||
<p className="text-slate-400 text-sm font-bold uppercase tracking-widest">No tienes mensajes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Agrupar mensajes por ListingId preliminarmente */}
|
||||
{Array.from(new Set(notes.map(n => n.listingId))).map(listingId => {
|
||||
const thread = notes.filter(n => n.listingId === listingId).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
const hasUnread = thread.some(n => !n.isRead && n.isFromModerator);
|
||||
const isExpanded = selectedThreadListingId === listingId;
|
||||
|
||||
return (
|
||||
<div key={listingId} className={clsx(
|
||||
"rounded-[2rem] border transition-all overflow-hidden",
|
||||
hasUnread ? "border-blue-200 bg-blue-50/30" : "border-slate-100 bg-slate-50/50"
|
||||
)}>
|
||||
{/* Cabecera del Hilo */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedThreadListingId(isExpanded ? null : listingId);
|
||||
if (!isExpanded) markThreadAsRead(listingId);
|
||||
}}
|
||||
className="w-full p-6 flex items-center justify-between hover:bg-white/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center transition-colors",
|
||||
hasUnread ? "bg-blue-600 text-white shadow-lg shadow-blue-100" : "bg-slate-200 text-slate-500"
|
||||
)}>
|
||||
<MessageSquare size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h4 className="font-black text-slate-900 uppercase text-xs">Conversación sobre Aviso #{listingId}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
|
||||
{thread.length} Mensajes • Último: {new Date(thread[thread.length - 1].createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className={clsx("text-slate-300 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
|
||||
{/* Cuerpo del Chat */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
className="overflow-hidden bg-white/40 border-t border-slate-100"
|
||||
>
|
||||
<div className="p-6 space-y-4">
|
||||
{thread.map((msg) => (
|
||||
<div key={msg.id} className={clsx(
|
||||
"flex flex-col max-w-[85%]",
|
||||
msg.isFromModerator ? "self-start" : "self-end ml-auto items-end"
|
||||
)}>
|
||||
<div className={clsx(
|
||||
"px-5 py-3 rounded-[1.5rem] shadow-sm text-sm",
|
||||
msg.isFromModerator
|
||||
? "bg-white border border-slate-100 text-slate-700 rounded-bl-none"
|
||||
: "bg-blue-600 text-white rounded-br-none"
|
||||
)}>
|
||||
{msg.message}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 px-2">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">
|
||||
{msg.isFromModerator ? 'Soporte SIG-CM' : 'Tú'} • {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{!msg.isRead && msg.isFromModerator && (
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Caja de Respuesta */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-100">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder="Escribe una respuesta al moderador..."
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl p-4 pr-14 text-sm focus:ring-2 focus:ring-blue-100 outline-none transition-all resize-none h-24 font-medium"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReply(listingId)}
|
||||
disabled={!replyText.trim()}
|
||||
className="absolute bottom-4 right-4 p-3 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-200 hover:bg-blue-700 disabled:opacity-50 disabled:shadow-none transition-all"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
{/* MODAL DE CONFIRMACIÓN */}
|
||||
{/* MODAL DE CONFIRMACIÓN DE REPUBLIVACIÓN PREMIUM */}
|
||||
<AnimatePresence>
|
||||
{republishTarget && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-slate-900/80 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="relative bg-white p-8 rounded-[2.5rem] shadow-2xl max-w-sm w-full text-center border border-slate-100"
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
className="relative bg-white rounded-[3rem] shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col border border-slate-100"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<RefreshCcw size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-3">Republicar Aviso</h3>
|
||||
<p className="text-sm text-slate-500 font-medium mb-8 leading-relaxed">
|
||||
Se creará una nueva publicación basada en <strong>{republishTarget.title}</strong>. Podrás editar los datos antes de realizar el pago.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{/* HEADER TIPO PANEL */}
|
||||
<div className="p-8 border-b border-slate-50 flex justify-between items-start bg-slate-50/50">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-2">
|
||||
<RefreshCcw size={12} className="animate-spin-slow" /> Republicar aviso
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">
|
||||
{republishTarget.title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="flex-1 py-4 px-6 bg-slate-100 text-slate-500 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-slate-200 transition-all"
|
||||
className="p-3 bg-white text-slate-400 hover:text-slate-900 rounded-2xl shadow-sm border border-slate-100 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmRepublish}
|
||||
className="flex-1 py-4 px-6 bg-blue-600 text-white font-black uppercase text-[10px] tracking-widest rounded-2xl shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
Confirmar
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CONTENIDO SCROLLABLE */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{loadingRepublish ? (
|
||||
<div className="py-20 text-center flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-100 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Recuperando ficha técnica...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
|
||||
{/* IZQUIERDA: Ficha y Galería */}
|
||||
<div className="lg:col-span-7 space-y-10">
|
||||
|
||||
{/* PREVIEW IMAGE GRID */}
|
||||
{republishDetail?.images?.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{republishDetail.images.slice(0, 2).map((img: any) => (
|
||||
<div key={img.id} className="aspect-video rounded-3xl overflow-hidden border border-slate-100 shadow-sm">
|
||||
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FICHA TÉCNICA MINI */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
|
||||
<Package size={14} /> Ficha Técnica a clonar
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{republishDetail?.attributes?.map((attr: any) => (
|
||||
<div key={attr.id} className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<span className="block text-[8px] font-black text-slate-400 uppercase mb-1">{attr.attributeName}</span>
|
||||
<span className="text-xs font-black text-slate-700 uppercase">{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TEXTO IMPRESO */}
|
||||
{republishDetail?.listing?.printText && (
|
||||
<div className="p-6 bg-blue-50/30 rounded-3xl border border-blue-50">
|
||||
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
|
||||
<Clock size={14} /> Texto de Impresión
|
||||
</h4>
|
||||
<p className="text-sm font-medium text-blue-900 italic leading-relaxed">
|
||||
"{republishDetail.listing.printText}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DERECHA: Advertencias y CTA */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* CARD DE PRECIO ACTUAL */}
|
||||
<div className="p-8 bg-slate-900 rounded-[2.5rem] shadow-2xl text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10">
|
||||
<RefreshCcw size={80} />
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 relative z-10">Último precio</p>
|
||||
<div className="text-4xl font-black text-emerald-400 tracking-tighter relative z-10 transition-all">
|
||||
${republishTarget.price.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-white/10 flex flex-col gap-2 relative z-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-black text-white/40 uppercase">Vistas totales</span>
|
||||
<span className="text-xs font-black flex items-center gap-1"><Eye size={12} /> {republishTarget.viewCount}</span>
|
||||
</div>
|
||||
{republishTarget.publicationStartDate && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-black text-white/40 uppercase">Publicado el</span>
|
||||
<span className="text-[10px] font-black">{new Date(republishTarget.publicationStartDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADVERTENCIA DE VIGENCIA */}
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = republishTarget.publicationStartDate ? new Date(republishTarget.publicationStartDate) : (republishTarget.approvedAt ? new Date(republishTarget.approvedAt) : null);
|
||||
const days = republishTarget.printDaysCount || 0;
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
const remainsActive = republishTarget.status === 'Published' && (endDate ? now <= endDate : true);
|
||||
|
||||
if (remainsActive) {
|
||||
const dateStr = endDate?.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric' }) || 'N/A';
|
||||
return (
|
||||
<div className="p-6 bg-amber-50 border border-amber-100 rounded-3xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-amber-500 text-white rounded-xl shadow-lg shadow-amber-200">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-amber-900 uppercase">Aviso Vigente</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-800 font-medium leading-relaxed">
|
||||
Este aviso caduca el <strong>{dateStr}</strong>. Si lo republicas generará un nuevo cargo duplicado.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* INFO DE CLONACIÓN */}
|
||||
<div className="p-6 bg-slate-50 rounded-3xl border border-slate-100">
|
||||
<p className="text-[10px] text-slate-500 font-bold leading-relaxed">
|
||||
Al confirmar, se abrirá el wizard con todos los datos cargados. Podrás ajustar precio, fotos y texto antes de pagar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ACCIONES */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
onClick={handleConfirmRepublish}
|
||||
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-3xl shadow-2xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
Confirmar y Editar <ChevronRight size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="w-full py-4 text-slate-400 font-black uppercase text-[10px] tracking-widest hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Cerrar Vista Previa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
@@ -444,30 +891,41 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
// COMPONENTES AUXILIARES PARA LIMPIEZA
|
||||
function SidebarItem({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
|
||||
function SidebarItem({ icon, label, active, onClick, badge }: { icon: any, label: string, active: boolean, onClick: () => void, badge?: number }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
|
||||
className={`w-full flex items-center justify-between px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
|
||||
? 'bg-blue-600 text-white shadow-xl shadow-blue-200'
|
||||
: 'text-slate-400 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
{badge ? badge > 0 && (
|
||||
<span className={clsx(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-black",
|
||||
active ? "bg-white text-blue-600" : "bg-rose-500 text-white shadow-lg shadow-rose-200"
|
||||
)}>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroup({ label, value, disabled, placeholder }: any) {
|
||||
function InputGroup({ label, value, onChange, disabled, placeholder }: any) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={value}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className={`w-full px-6 py-4 bg-slate-50 border-none rounded-2xl focus:ring-2 focus:ring-blue-100 outline-none transition-all font-bold text-slate-700 ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-100'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWizardStore } from '../store/wizardStore';
|
||||
import CategorySelection from './Steps/CategorySelection';
|
||||
import OperationSelection from './Steps/OperationSelection';
|
||||
@@ -9,13 +10,24 @@ import SummaryStep from './Steps/SummaryStep';
|
||||
import { wizardService } from '../services/wizardService';
|
||||
import type { AttributeDefinition } from '../types';
|
||||
import SEO from '../components/SEO';
|
||||
import api from '../services/api';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
|
||||
export default function PublishPage() {
|
||||
const { step, selectedCategory } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
const [profileComplete, setProfileComplete] = useState<boolean | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/profile').then(res => {
|
||||
const data = res.data;
|
||||
const isComplete = !!(data.billingName && data.billingAddress && data.billingTaxId && data.billingTaxType);
|
||||
setProfileComplete(isComplete);
|
||||
}).catch(() => setProfileComplete(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// CAMBIO: Agregamos el guard aquí también
|
||||
if (selectedCategory?.id) {
|
||||
wizardService.getAttributes(selectedCategory.id)
|
||||
.then(setDefinitions)
|
||||
@@ -23,6 +35,34 @@ export default function PublishPage() {
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
if (profileComplete === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileComplete === false) {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center p-10 text-center animate-in fade-in zoom-in duration-500">
|
||||
<div className="w-24 h-24 bg-amber-50 text-amber-500 rounded-[2.5rem] flex items-center justify-center mb-8 shadow-xl shadow-amber-100 border border-amber-100">
|
||||
<UserIcon size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter mb-4">Perfil Incompleto</h2>
|
||||
<p className="text-slate-500 max-w-sm mb-10 font-medium leading-relaxed">
|
||||
Para poder publicar un aviso, primero debes completar tus datos de facturación. Esto es obligatorio para la emisión legal de comprobantes.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/profile?tab=settings')}
|
||||
className="bg-slate-900 text-white px-12 py-5 rounded-[2rem] font-black uppercase text-xs tracking-widest shadow-2xl shadow-slate-200 hover:bg-blue-600 transition-all active:scale-95"
|
||||
>
|
||||
Completar mis datos ahora
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
|
||||
<SEO
|
||||
|
||||
@@ -61,7 +61,11 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
printFontSize: 'normal',
|
||||
printAlignment: 'left'
|
||||
printAlignment: 'left',
|
||||
publicationStartDate: attributes['publicationStartDate'],
|
||||
isFeatured: attributes['isFeatured'] === 'true',
|
||||
allowContact: attributes['allowContact'] === 'true',
|
||||
couponCode: attributes['couponCode']
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
|
||||
@@ -13,27 +13,35 @@ interface PricingResult {
|
||||
wordCount: number;
|
||||
specialCharCount: number;
|
||||
details: string;
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export default function TextEditorStep() {
|
||||
const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [text, setText] = useState(attributes['description'] || '');
|
||||
const [days, setDays] = useState(parseInt(attributes['days'] as string) || 3);
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [isFeatured, setIsFeatured] = useState<boolean>(false);
|
||||
const [allowContact, setAllowContact] = useState<boolean>(true);
|
||||
const [couponCode, setCouponCode] = useState<string>('');
|
||||
|
||||
const [pricing, setPricing] = useState<PricingResult>({
|
||||
totalPrice: 0,
|
||||
baseCost: 0,
|
||||
extraCost: 0,
|
||||
wordCount: 0,
|
||||
specialCharCount: 0,
|
||||
details: ''
|
||||
details: '',
|
||||
discount: 0
|
||||
});
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
|
||||
const debouncedText = useDebounce(text, 800);
|
||||
const debouncedCoupon = useDebounce(couponCode, 800);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategory || debouncedText.length === 0) {
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '' });
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '', discount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,9 +52,11 @@ export default function TextEditorStep() {
|
||||
categoryId: selectedCategory.id,
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
startDate: new Date().toISOString()
|
||||
isBold: false, // Could expose this too
|
||||
isFrame: false, // Could expose this too
|
||||
isFeatured: isFeatured,
|
||||
startDate: startDate.toISOString(),
|
||||
couponCode: debouncedCoupon
|
||||
});
|
||||
setPricing(response.data);
|
||||
} catch (error) {
|
||||
@@ -57,7 +67,7 @@ export default function TextEditorStep() {
|
||||
};
|
||||
|
||||
calculatePrice();
|
||||
}, [debouncedText, days, selectedCategory]);
|
||||
}, [debouncedText, days, selectedCategory, isFeatured, startDate, debouncedCoupon]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (text.trim().length === 0) {
|
||||
@@ -67,6 +77,10 @@ export default function TextEditorStep() {
|
||||
setAttribute('description', text);
|
||||
setAttribute('days', days.toString());
|
||||
setAttribute('adFee', pricing.totalPrice.toString());
|
||||
setAttribute('publicationStartDate', startDate.toISOString());
|
||||
setAttribute('allowContact', allowContact.toString());
|
||||
setAttribute('isFeatured', isFeatured.toString());
|
||||
setAttribute('couponCode', couponCode);
|
||||
setStep(5); // Siguiente paso
|
||||
};
|
||||
|
||||
@@ -128,28 +142,88 @@ export default function TextEditorStep() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE DÍAS */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE PUBLICACIÓN */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 space-y-8">
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-between">
|
||||
{/* Días */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración</h4>
|
||||
<div className="flex items-center bg-slate-100 rounded-xl p-1 mt-2">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-12 text-center bg-transparent border-none outline-none font-black text-lg text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración de la oferta</h4>
|
||||
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-wider">¿Cuántos días se publicará?</p>
|
||||
|
||||
{/* Fecha de Inicio */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-blue-50 rounded-2xl text-blue-600">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Fecha de Inicio</h4>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate.toISOString().split('T')[0]}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setStartDate(new Date(e.target.value))}
|
||||
className="mt-2 text-sm font-bold text-slate-700 bg-slate-100 rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-slate-100 rounded-2xl p-1 w-full md:w-48">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-16 text-center bg-transparent border-none outline-none font-black text-xl text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">+</button>
|
||||
<div className="h-px bg-slate-100 w-full"></div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-between items-start">
|
||||
{/* Destacado */}
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${isFeatured ? 'bg-amber-400' : 'bg-slate-200'}`}>
|
||||
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${isFeatured ? 'translate-x-6' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-amber-500 transition-colors">Destacar Aviso</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold">Posición preferencial (+ $500/día)</p>
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" checked={isFeatured} onChange={(e) => setIsFeatured(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{/* Permitir Contacto */}
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${allowContact ? 'bg-green-500' : 'bg-slate-200'}`}>
|
||||
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${allowContact ? 'translate-x-6' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-green-600 transition-colors">Permitir Contacto</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold">Mostrar botón de WhatsApp/Email</p>
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" checked={allowContact} onChange={(e) => setAllowContact(e.target.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-2xl flex items-center gap-4">
|
||||
<span className="text-xs font-black uppercase text-slate-400">Cupón:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value.toUpperCase())}
|
||||
placeholder="CÓDIGO"
|
||||
className="bg-transparent border-b-2 border-slate-200 focus:border-blue-500 outline-none font-mono font-bold text-slate-700 w-32 text-center uppercase"
|
||||
/>
|
||||
{pricing.discount > 0 && <span className="text-xs font-bold text-green-600">¡Aplicado!</span>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 3: PREVISUALIZACIÓN */}
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface Listing {
|
||||
images?: ListingImage[];
|
||||
viewCount: number;
|
||||
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
isFeatured?: boolean;
|
||||
featuredExpiry?: string;
|
||||
allowContact?: boolean;
|
||||
printDaysCount?: number;
|
||||
}
|
||||
|
||||
export interface ListingAttribute {
|
||||
@@ -77,4 +83,8 @@ export interface CreateListingDto {
|
||||
printFontSize?: string;
|
||||
printAlignment?: string;
|
||||
imagesToClone?: string[];
|
||||
publicationStartDate?: string;
|
||||
isFeatured?: boolean;
|
||||
allowContact?: boolean;
|
||||
couponCode?: string;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Application.Interfaces;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
@@ -10,10 +12,12 @@ namespace SIGCM.API.Controllers;
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
public AuthController(IAuthService authService, AuditRepository auditRepo)
|
||||
{
|
||||
_authService = authService;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
// Inicio de sesión tradicional
|
||||
@@ -22,6 +26,21 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
var result = await _authService.LoginAsync(dto.Username, dto.Password);
|
||||
if (!result.Success) return Unauthorized(new { message = result.ErrorMessage });
|
||||
|
||||
// Audit Log
|
||||
if (result.UserId.HasValue)
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = result.UserId.Value,
|
||||
Action = "LOGIN",
|
||||
EntityId = result.UserId.Value,
|
||||
EntityType = "User",
|
||||
Details = $"Inicio de sesión exitoso: {dto.Username}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -63,6 +82,18 @@ public class AuthController : ControllerBase
|
||||
if (!valid) return BadRequest(new { message = "Código inválido" });
|
||||
|
||||
await _authService.EnableMfaAsync(userId, true);
|
||||
|
||||
// Audit Log
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "ENABLE_MFA",
|
||||
EntityId = userId,
|
||||
EntityType = "User",
|
||||
Details = "Autenticación de Dos Factores activada",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public class CashSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("open")]
|
||||
public async Task<IActionResult> Open([FromBody] decimal openingBalance)
|
||||
public async Task<IActionResult> Open([FromBody] CashOpeningDto dto)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
@@ -43,7 +43,7 @@ public class CashSessionsController : ControllerBase
|
||||
var existing = await _repo.GetActiveSessionAsync(userId);
|
||||
if (existing != null) return BadRequest("Ya tienes una caja abierta");
|
||||
|
||||
var id = await _repo.OpenSessionAsync(userId, openingBalance);
|
||||
var id = await _repo.OpenSessionAsync(userId, dto.OpeningBalance);
|
||||
|
||||
// Opcional: Auditar la apertura
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
@@ -52,7 +52,7 @@ public class CashSessionsController : ControllerBase
|
||||
Action = "CASH_SESSION_OPENED",
|
||||
EntityId = id,
|
||||
EntityType = "CashSession",
|
||||
Details = $"Caja abierta con fondo: ${openingBalance}",
|
||||
Details = $"Caja abierta con fondo: ${dto.OpeningBalance}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ public class CategoriesController : ControllerBase
|
||||
|
||||
var id = await _repository.AddAsync(category);
|
||||
category.Id = id;
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "CREATE_CATEGORY",
|
||||
EntityId = id,
|
||||
EntityType = "Category",
|
||||
Details = $"Rubro '{category.Name}' creado (Padre ID: {category.ParentId})",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id }, category);
|
||||
}
|
||||
|
||||
@@ -71,6 +87,22 @@ public class CategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
await _repository.UpdateAsync(category);
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "UPDATE_CATEGORY",
|
||||
EntityId = id,
|
||||
EntityType = "Category",
|
||||
Details = $"Rubro ID #{id} actualizado a '{category.Name}'",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -127,6 +159,22 @@ public class CategoriesController : ControllerBase
|
||||
{
|
||||
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
|
||||
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId);
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "MERGE_CATEGORIES",
|
||||
EntityId = request.TargetId,
|
||||
EntityType = "Category",
|
||||
Details = $"Rubro ID #{request.SourceId} fusionado en ID #{request.TargetId}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { message = "Fusión completada." });
|
||||
}
|
||||
|
||||
@@ -143,6 +191,22 @@ public class CategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId);
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "MOVE_CONTENT",
|
||||
EntityId = request.TargetId,
|
||||
EntityType = "Listing",
|
||||
Details = $"Avisos movidos del Rubro ID #{request.SourceId} al ID #{request.TargetId}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { message = "Avisos movidos correctamente." });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ namespace SIGCM.API.Controllers;
|
||||
public class ClientsController : ControllerBase
|
||||
{
|
||||
private readonly ClientRepository _repo;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
|
||||
public ClientsController(ClientRepository repo)
|
||||
public ClientsController(ClientRepository repo, AuditRepository auditRepo)
|
||||
{
|
||||
_repo = repo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
@@ -36,6 +38,22 @@ public class ClientsController : ControllerBase
|
||||
{
|
||||
if (id != client.Id) return BadRequest("ID de URL no coincide con el cuerpo");
|
||||
await _repo.UpdateAsync(client);
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "UPDATE_CLIENT",
|
||||
EntityId = id,
|
||||
EntityType = "Client",
|
||||
Details = $"Cliente {client.Name} actualizado (DNI/CUIT: {client.DniOrCuit})",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
97
src/SIGCM.API/Controllers/CouponsController.cs
Normal file
97
src/SIGCM.API/Controllers/CouponsController.cs
Normal 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; }
|
||||
}
|
||||
@@ -14,13 +14,17 @@ public class ListingsController : ControllerBase
|
||||
{
|
||||
private readonly IListingRepository _repository;
|
||||
private readonly ClientRepository _clientRepo;
|
||||
private readonly IUserRepository _userRepo;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
private readonly ICouponRepository _couponRepo;
|
||||
|
||||
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
|
||||
public ListingsController(IListingRepository repository, ClientRepository clientRepo, IUserRepository userRepo, AuditRepository auditRepo, ICouponRepository couponRepo)
|
||||
{
|
||||
_repository = repository;
|
||||
_clientRepo = clientRepo;
|
||||
_userRepo = userRepo;
|
||||
_auditRepo = auditRepo;
|
||||
_couponRepo = couponRepo;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -37,12 +41,29 @@ public class ListingsController : ControllerBase
|
||||
}
|
||||
|
||||
int? clientId = null;
|
||||
var user = await _userRepo.GetByIdAsync(currentUserId);
|
||||
|
||||
// Lógica de Cliente (asegurar existencia en BD)
|
||||
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
|
||||
// Lógica de Vinculación Usuario-Cliente
|
||||
if (user != null)
|
||||
{
|
||||
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
|
||||
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
|
||||
clientId = user.ClientId;
|
||||
|
||||
// Si el usuario no tiene cliente vinculado, pero envía datos de facturación (o registramos los que tiene)
|
||||
if (clientId == null)
|
||||
{
|
||||
string? dniToUse = dto.ClientDni ?? user.BillingTaxId;
|
||||
if (!string.IsNullOrWhiteSpace(dniToUse))
|
||||
{
|
||||
string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username;
|
||||
clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!);
|
||||
|
||||
// Actualizamos el usuario con su nuevo nexo de cliente
|
||||
user.ClientId = clientId;
|
||||
user.BillingTaxId = dniToUse;
|
||||
user.BillingName = nameToUse;
|
||||
await _userRepo.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var listing = new Listing
|
||||
@@ -56,19 +77,43 @@ public class ListingsController : ControllerBase
|
||||
Currency = "ARS",
|
||||
UserId = currentUserId,
|
||||
ClientId = clientId,
|
||||
Origin = dto.Origin,
|
||||
Origin = dto.Origin ?? "Web",
|
||||
Status = dto.Status,
|
||||
ImagesToClone = dto.ImagesToClone,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PrintText = dto.PrintText,
|
||||
PrintDaysCount = dto.PrintDaysCount,
|
||||
PrintStartDate = dto.PrintStartDate,
|
||||
PrintStartDate = dto.PrintStartDate ?? dto.PublicationStartDate,
|
||||
IsBold = dto.IsBold,
|
||||
IsFrame = dto.IsFrame,
|
||||
PrintFontSize = dto.PrintFontSize,
|
||||
PrintAlignment = dto.PrintAlignment
|
||||
PrintAlignment = dto.PrintAlignment,
|
||||
// Nuevos Campos
|
||||
PublicationStartDate = dto.PublicationStartDate,
|
||||
IsFeatured = dto.IsFeatured,
|
||||
AllowContact = dto.AllowContact,
|
||||
CouponCode = dto.CouponCode
|
||||
};
|
||||
|
||||
// Validar límite de uso de cupón por usuario
|
||||
if (!string.IsNullOrEmpty(dto.CouponCode))
|
||||
{
|
||||
var coupon = await _couponRepo.GetByCodeAsync(dto.CouponCode);
|
||||
if (coupon != null && coupon.MaxUsagesPerUser.HasValue)
|
||||
{
|
||||
// Contar usos previos
|
||||
var userUsageCount = await _couponRepo.CountUserUsageAsync(currentUserId, coupon.Code);
|
||||
if (userUsageCount >= coupon.MaxUsagesPerUser.Value)
|
||||
{
|
||||
return BadRequest(new { message = $"El cupón {dto.CouponCode} ya ha superado el límite de uso para este usuario." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lógica de fecha de inicio: Si el usuario solicitó una fecha futura, ApprovedAt permanece null hasta esa fecha.
|
||||
// La lógica actual establece ApprovedAt al aprobar en moderación.
|
||||
// PublicationStartDate es solo una referencia de solicitud.
|
||||
|
||||
// Conversión de Pagos
|
||||
List<Payment>? payments = null;
|
||||
if (dto.Payments != null && dto.Payments.Any())
|
||||
@@ -85,6 +130,16 @@ public class ListingsController : ControllerBase
|
||||
|
||||
var id = await _repository.CreateAsync(listing, dto.Attributes, payments);
|
||||
|
||||
// Coupon Usage Logic
|
||||
if (!string.IsNullOrEmpty(dto.CouponCode))
|
||||
{
|
||||
var coupon = await _couponRepo.GetByCodeAsync(dto.CouponCode);
|
||||
if (coupon != null)
|
||||
{
|
||||
await _couponRepo.IncrementUsageAsync(coupon.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Registro de Auditoría
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
@@ -92,7 +147,7 @@ public class ListingsController : ControllerBase
|
||||
Action = "CREATE_LISTING",
|
||||
EntityId = id,
|
||||
EntityType = "Listing",
|
||||
Details = $"Aviso creado por usuario autenticado. Total: ${dto.AdFee}",
|
||||
Details = $"Aviso creado por usuario autenticado. Total: ${dto.AdFee}. Featured: {dto.IsFeatured}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
@@ -144,7 +199,8 @@ public class ListingsController : ControllerBase
|
||||
request.From,
|
||||
request.To,
|
||||
request.Origin,
|
||||
request.Status
|
||||
request.Status,
|
||||
request.OnlyActive
|
||||
);
|
||||
return Ok(results);
|
||||
}
|
||||
@@ -159,28 +215,41 @@ public class ListingsController : ControllerBase
|
||||
|
||||
[HttpPut("{id}/status")]
|
||||
[Authorize(Roles = "Admin,Moderador")]
|
||||
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
|
||||
public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateStatusRequest request)
|
||||
{
|
||||
// Obtener el ID de quien realiza la acción (Administrador/Moderador)
|
||||
var userIdStr = User.FindFirst("Id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId))
|
||||
return Unauthorized();
|
||||
|
||||
await _repository.UpdateStatusAsync(id, status);
|
||||
await _repository.UpdateStatusAsync(id, request.Status);
|
||||
|
||||
string actionDetails = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {request.Status}";
|
||||
if (!string.IsNullOrEmpty(request.Note))
|
||||
{
|
||||
actionDetails += $". Nota al usuario: {request.Note}";
|
||||
// TODO: Create actual Notification record for user
|
||||
}
|
||||
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = currentUserId,
|
||||
Action = status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
|
||||
Action = request.Status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
|
||||
EntityId = id,
|
||||
EntityType = "Listing",
|
||||
Details = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {status}",
|
||||
Details = actionDetails,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public class UpdateStatusRequest
|
||||
{
|
||||
public required string Status { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
public class SearchRequest
|
||||
{
|
||||
public string? Query { get; set; }
|
||||
@@ -190,6 +259,7 @@ public class ListingsController : ControllerBase
|
||||
public DateTime? To { get; set; }
|
||||
public string? Origin { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool OnlyActive { get; set; } = false;
|
||||
}
|
||||
|
||||
[HttpGet("pending/count")]
|
||||
|
||||
77
src/SIGCM.API/Controllers/NotesController.cs
Normal file
77
src/SIGCM.API/Controllers/NotesController.cs
Normal 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; }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Services;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
@@ -13,11 +14,13 @@ public class PaymentsController : ControllerBase
|
||||
{
|
||||
private readonly MercadoPagoService _mpService;
|
||||
private readonly IListingRepository _listingRepo;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
|
||||
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo)
|
||||
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo, AuditRepository auditRepo)
|
||||
{
|
||||
_mpService = mpService;
|
||||
_listingRepo = listingRepo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -73,6 +76,22 @@ public class PaymentsController : ControllerBase
|
||||
};
|
||||
|
||||
await _listingRepo.AddPaymentAsync(paymentRecord);
|
||||
|
||||
// Audit Log
|
||||
var userIdStr = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdStr, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "CONFIRM_PAYMENT",
|
||||
EntityId = listingId,
|
||||
EntityType = "Payment",
|
||||
Details = $"Pago confirmado (Simulado). Monto: ${request.Amount}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { message = "Pago registrado. Aviso enviado a moderación." });
|
||||
}
|
||||
|
||||
|
||||
66
src/SIGCM.API/Controllers/ProfileController.cs
Normal file
66
src/SIGCM.API/Controllers/ProfileController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,12 @@ public class ReportsController : ControllerBase
|
||||
|
||||
[HttpGet("audit")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetAuditLogs()
|
||||
public async Task<IActionResult> GetAuditLogs([FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? userId)
|
||||
{
|
||||
// Obtenemos los últimos 100 eventos
|
||||
var logs = await _auditRepo.GetRecentLogsAsync(100);
|
||||
var start = from ?? DateTime.UtcNow.Date;
|
||||
var end = to ?? DateTime.UtcNow.AddDays(1).Date.AddSeconds(-1);
|
||||
|
||||
var logs = await _auditRepo.GetFilteredLogsAsync(start, end, userId);
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
|
||||
86
src/SIGCM.API/Controllers/UserNotesController.cs
Normal file
86
src/SIGCM.API/Controllers/UserNotesController.cs
Normal 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; }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
@@ -12,18 +13,22 @@ namespace SIGCM.API.Controllers;
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserRepository _repository;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
|
||||
public UsersController(IUserRepository repository)
|
||||
public UsersController(IUserRepository repository, AuditRepository auditRepo)
|
||||
{
|
||||
_repository = repository;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var users = await _repository.GetAllAsync();
|
||||
// Don't expose password hashes
|
||||
var sanitized = users.Select(u => new {
|
||||
// Excluimos clientes para que solo aparezcan en su propio gestor
|
||||
var sanitized = users
|
||||
.Where(u => u.Role != "Client")
|
||||
.Select(u => new {
|
||||
u.Id, u.Username, u.Role, u.Email, u.CreatedAt
|
||||
});
|
||||
return Ok(sanitized);
|
||||
@@ -57,6 +62,22 @@ public class UsersController : ControllerBase
|
||||
};
|
||||
|
||||
var id = await _repository.CreateAsync(user);
|
||||
|
||||
// Audit Log
|
||||
var currentUserIdStr = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(currentUserIdStr, out int currentUserId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = currentUserId,
|
||||
Action = "CREATE_USER",
|
||||
EntityId = id,
|
||||
EntityType = "User",
|
||||
Details = $"Usuario {dto.Username} creado con rol {dto.Role}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id }, new { id, user.Username });
|
||||
}
|
||||
|
||||
@@ -76,6 +97,22 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
await _repository.UpdateAsync(user);
|
||||
|
||||
// Audit Log
|
||||
var currentUserIdStr = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(currentUserIdStr, out int currentUserId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = currentUserId,
|
||||
Action = "UPDATE_USER",
|
||||
EntityId = id,
|
||||
EntityType = "User",
|
||||
Details = $"Usuario {user.Username} actualizado (Rol: {user.Role})",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -85,6 +122,22 @@ public class UsersController : ControllerBase
|
||||
// Safe check: prevent deleting yourself optional but good practice
|
||||
// For now simple delete
|
||||
await _repository.DeleteAsync(id);
|
||||
|
||||
// Audit Log
|
||||
var currentUserIdStr = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(currentUserIdStr, out int currentUserId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = currentUserId,
|
||||
Action = "DELETE_USER",
|
||||
EntityId = id,
|
||||
EntityType = "User",
|
||||
Details = $"Usuario ID #{id} eliminado",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -8,4 +8,5 @@ public class AuthResult
|
||||
public bool IsLockedOut { get; set; }
|
||||
public bool RequiresPasswordChange { get; set; }
|
||||
public bool RequiresMfa { get; set; }
|
||||
public int? UserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ public class CashClosingDto
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class CashOpeningDto
|
||||
{
|
||||
public decimal OpeningBalance { get; set; }
|
||||
}
|
||||
|
||||
// DTO de respuesta con el resultado del cierre
|
||||
public class CashClosingResultDto
|
||||
{
|
||||
|
||||
@@ -27,6 +27,12 @@ public class CreateListingDto
|
||||
public string PrintFontSize { get; set; } = "normal";
|
||||
public string PrintAlignment { get; set; } = "left";
|
||||
|
||||
// Public Web Features
|
||||
public DateTime? PublicationStartDate { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public bool AllowContact { get; set; } = true;
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
// Atributos Dinámicos
|
||||
public Dictionary<int, string> Attributes { get; set; } = new();
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ public class CalculatePriceRequest
|
||||
public int Days { get; set; }
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsFrame { get; set; }
|
||||
public bool IsFeatured { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public string? CouponCode { get; set; }
|
||||
}
|
||||
|
||||
public class CalculatePriceResponse
|
||||
|
||||
@@ -15,3 +15,12 @@ public class UpdateUserDto
|
||||
public required string Role { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProfileDto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? BillingName { get; set; }
|
||||
public string? BillingAddress { get; set; }
|
||||
public string? BillingTaxId { get; set; }
|
||||
public string? BillingTaxType { get; set; }
|
||||
}
|
||||
|
||||
15
src/SIGCM.Domain/Entities/Coupon.cs
Normal file
15
src/SIGCM.Domain/Entities/Coupon.cs
Normal 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;
|
||||
}
|
||||
@@ -30,5 +30,15 @@ public class Listing
|
||||
|
||||
// Propiedades auxiliares
|
||||
public string? CategoryName { get; set; }
|
||||
public string? ParentCategoryName { get; set; }
|
||||
public string? MainImageUrl { get; set; }
|
||||
|
||||
// Propiedades de Publicación y Gestión
|
||||
public DateTime? PublicationStartDate { get; set; } // Fecha inicio solicitada por usuario
|
||||
public DateTime? ApprovedAt { get; set; } // Fecha real de aprobación (inicio de contador de días)
|
||||
public bool IsFeatured { get; set; }
|
||||
public DateTime? FeaturedExpiry { get; set; }
|
||||
public bool AllowContact { get; set; } = true;
|
||||
public string? CouponCode { get; set; }
|
||||
public int UnreadNotesCount { get; set; }
|
||||
}
|
||||
12
src/SIGCM.Domain/Entities/ListingNote.cs
Normal file
12
src/SIGCM.Domain/Entities/ListingNote.cs
Normal 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;
|
||||
}
|
||||
@@ -20,4 +20,12 @@ public class User
|
||||
public string? GoogleId { get; set; }
|
||||
public bool IsMfaEnabled { get; set; }
|
||||
public string? MfaSecret { get; set; }
|
||||
|
||||
// Datos de Facturación y Contacto (Unificados con Clientes)
|
||||
public int? ClientId { get; set; } // Opcional: ID de herencia o referencia externa
|
||||
public string? BillingName { get; set; } // Equivale a Client.Name
|
||||
public string? BillingAddress { get; set; } // Equivale a Client.Address
|
||||
public string? BillingTaxId { get; set; } // CUIT/DNI
|
||||
public string? BillingTaxType { get; set; } // Condición fiscal
|
||||
public string? Phone { get; set; } // Nuevo: Teléfono de contacto
|
||||
}
|
||||
|
||||
13
src/SIGCM.Domain/Interfaces/ICouponRepository.cs
Normal file
13
src/SIGCM.Domain/Interfaces/ICouponRepository.cs
Normal 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);
|
||||
}
|
||||
12
src/SIGCM.Domain/Interfaces/IListingNoteRepository.cs
Normal file
12
src/SIGCM.Domain/Interfaces/IListingNoteRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public interface IListingRepository
|
||||
DateTime? from = null,
|
||||
DateTime? to = null,
|
||||
string? origin = null,
|
||||
string? status = null
|
||||
string? status = null,
|
||||
bool onlyActive = false
|
||||
);
|
||||
|
||||
// Impresión
|
||||
|
||||
@@ -26,4 +26,5 @@ public class ReportItemDto
|
||||
public string Cashier { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Source { get; set; } = "";
|
||||
public string? ClientName { get; set; }
|
||||
}
|
||||
@@ -116,9 +116,181 @@ BEGIN
|
||||
);
|
||||
END
|
||||
";
|
||||
// Ejecutar creación de tablas
|
||||
// Ejecutar creación de tablas base
|
||||
await connection.ExecuteAsync(schemaSql);
|
||||
|
||||
// --- MIGRACIONES (Schema Update) ---
|
||||
var migrationSql = @"
|
||||
-- Listings Columns
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ApprovedAt' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD ApprovedAt DATETIME2 NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFeatured' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD IsFeatured BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FeaturedExpiry' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD FeaturedExpiry DATETIME2 NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'AllowContact' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD AllowContact BIT NOT NULL DEFAULT 1;
|
||||
|
||||
-- Users Columns (Security & Profiles)
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingName' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD BillingName NVARCHAR(200) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingAddress' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD BillingAddress NVARCHAR(500) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxId' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD BillingTaxId NVARCHAR(50) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'BillingTaxType' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD BillingTaxType NVARCHAR(50) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'ClientId' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD ClientId INT NULL;
|
||||
|
||||
-- Audit & Security Columns for Users
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'FailedLoginAttempts' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD FailedLoginAttempts INT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LockoutEnd' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD LockoutEnd DATETIME NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MustChangePassword' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD MustChangePassword BIT NOT NULL DEFAULT 1;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsActive' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD IsActive BIT NOT NULL DEFAULT 1;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'LastLogin' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD LastLogin DATETIME NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'GoogleId' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD GoogleId NVARCHAR(255) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsMfaEnabled' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD IsMfaEnabled BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MfaSecret' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD MfaSecret NVARCHAR(255) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Users'))
|
||||
ALTER TABLE Users ADD Phone NVARCHAR(50) NULL;
|
||||
|
||||
-- Coupons Table
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Coupons')
|
||||
BEGIN
|
||||
CREATE TABLE Coupons (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
Code NVARCHAR(50) NOT NULL,
|
||||
DiscountType NVARCHAR(20) NOT NULL,
|
||||
DiscountValue DECIMAL(18,2) NOT NULL,
|
||||
ExpiryDate DATETIME2 NULL,
|
||||
UsageCount INT NOT NULL DEFAULT 0,
|
||||
MaxUsages INT NULL,
|
||||
IsActive BIT NOT NULL DEFAULT 1,
|
||||
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()
|
||||
);
|
||||
CREATE UNIQUE INDEX IX_Coupons_Code ON Coupons(Code);
|
||||
|
||||
-- Seed Default Coupon
|
||||
INSERT INTO Coupons (Code, DiscountType, DiscountValue) VALUES ('WELCOME2025', 'Percentage', 10.00);
|
||||
END
|
||||
|
||||
-- Listings CouponCode
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'CouponCode' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD CouponCode NVARCHAR(50) NULL;
|
||||
|
||||
-- Coupons MaxUsagesPerUser
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'MaxUsagesPerUser' AND Object_ID = Object_ID(N'Coupons'))
|
||||
ALTER TABLE Coupons ADD MaxUsagesPerUser INT NULL;
|
||||
|
||||
-- Tabla de Notas/Mensajes de Avisos
|
||||
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'ListingNotes') AND type in (N'U'))
|
||||
BEGIN
|
||||
CREATE TABLE ListingNotes (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
ListingId INT NOT NULL,
|
||||
SenderId INT NOT NULL,
|
||||
IsFromModerator BIT NOT NULL DEFAULT 1,
|
||||
Message NVARCHAR(MAX) NOT NULL,
|
||||
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
|
||||
IsRead BIT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (SenderId) REFERENCES Users(Id)
|
||||
);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
-- Migración gradual si la tabla ya existía con ModeratorId
|
||||
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'ModeratorId' AND Object_ID = Object_ID(N'ListingNotes'))
|
||||
BEGIN
|
||||
EXEC sp_rename 'ListingNotes.ModeratorId', 'SenderId', 'COLUMN';
|
||||
END
|
||||
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsFromModerator' AND Object_ID = Object_ID(N'ListingNotes'))
|
||||
BEGIN
|
||||
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
|
||||
END
|
||||
END
|
||||
";
|
||||
await connection.ExecuteAsync(migrationSql);
|
||||
|
||||
// --- MIGRACIÓN DE DATOS (Post-Schema Update) ---
|
||||
var dataMigrationSql = @"
|
||||
-- Data Migration: Clients to Users
|
||||
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'Clients')
|
||||
BEGIN
|
||||
-- Verificamos si la tabla Clients tiene la columna Phone para evitar errores
|
||||
DECLARE @phoneColumnExists BIT = 0;
|
||||
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'Phone' AND Object_ID = Object_ID(N'Clients'))
|
||||
SET @phoneColumnExists = 1;
|
||||
|
||||
IF @phoneColumnExists = 1
|
||||
BEGIN
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, Phone, MustChangePassword, IsActive)
|
||||
SELECT
|
||||
ISNULL(DniOrCuit, Name) as Username,
|
||||
'N/A' as PasswordHash,
|
||||
'Client' as Role,
|
||||
Email,
|
||||
Name as BillingName,
|
||||
DniOrCuit as BillingTaxId,
|
||||
Address as BillingAddress,
|
||||
Phone,
|
||||
0 as MustChangePassword,
|
||||
1 as IsActive
|
||||
FROM Clients
|
||||
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, BillingName, BillingTaxId, BillingAddress, MustChangePassword, IsActive)
|
||||
SELECT
|
||||
ISNULL(DniOrCuit, Name) as Username,
|
||||
'N/A' as PasswordHash,
|
||||
'Client' as Role,
|
||||
Email,
|
||||
Name as BillingName,
|
||||
DniOrCuit as BillingTaxId,
|
||||
Address as BillingAddress,
|
||||
0 as MustChangePassword,
|
||||
1 as IsActive
|
||||
FROM Clients
|
||||
WHERE DniOrCuit NOT IN (SELECT BillingTaxId FROM Users WHERE BillingTaxId IS NOT NULL);
|
||||
END
|
||||
|
||||
-- 1. Actualizar Listings para que apunten a Users antes de romper nada (opcional pero recomendado si hay datos)
|
||||
-- Solo lo hacemos para los que ya existen en Users por BillingTaxId
|
||||
UPDATE l
|
||||
SET l.ClientId = u.Id
|
||||
FROM Listings l
|
||||
JOIN Clients c ON l.ClientId = c.Id
|
||||
JOIN Users u ON c.DniOrCuit = u.BillingTaxId;
|
||||
|
||||
-- 2. Eliminar todas las llaves foráneas que apuntan a Clients
|
||||
DECLARE @sql NVARCHAR(MAX) = N'';
|
||||
SELECT @sql += 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(parent_object_id)) +
|
||||
' DROP CONSTRAINT ' + QUOTENAME(name) + ';'
|
||||
FROM sys.foreign_keys
|
||||
WHERE referenced_object_id = OBJECT_ID('Clients');
|
||||
|
||||
IF @sql <> '' EXEC sp_executesql @sql;
|
||||
|
||||
-- 3. Finalmente borrar la tabla
|
||||
DROP TABLE Clients;
|
||||
END
|
||||
";
|
||||
await connection.ExecuteAsync(dataMigrationSql);
|
||||
|
||||
// --- SEED DE DATOS (Usuario Admin) ---
|
||||
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");
|
||||
|
||||
|
||||
69
src/SIGCM.Infrastructure/Data/UPDATE_SCHEMA.sql
Normal file
69
src/SIGCM.Infrastructure/Data/UPDATE_SCHEMA.sql
Normal 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;
|
||||
@@ -32,6 +32,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<ImageOptimizationService>();
|
||||
services.AddScoped<IClaimRepository, ClaimRepository>();
|
||||
services.AddScoped<NotificationRepository>();
|
||||
services.AddScoped<ICouponRepository, CouponRepository>();
|
||||
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
|
||||
|
||||
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
||||
services.AddScoped<MercadoPagoService>(sp =>
|
||||
|
||||
@@ -37,4 +37,19 @@ public class AuditRepository
|
||||
ORDER BY a.CreatedAt DESC";
|
||||
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AuditLog>> GetFilteredLogsAsync(DateTime from, DateTime to, int? userId = null)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"SELECT a.*, u.Username
|
||||
FROM AuditLogs a
|
||||
JOIN Users u ON a.UserId = u.Id
|
||||
WHERE a.CreatedAt >= @From AND a.CreatedAt <= @To";
|
||||
|
||||
if (userId.HasValue) sql += " AND a.UserId = @UserId";
|
||||
|
||||
sql += " ORDER BY a.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<AuditLog>(sql, new { From = from, To = to, UserId = userId });
|
||||
}
|
||||
}
|
||||
@@ -13,59 +13,63 @@ public class ClientRepository
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// Búsqueda inteligente con protección de nulos
|
||||
// Búsqueda inteligente redireccionada a Users
|
||||
public async Task<IEnumerable<Client>> SearchAsync(string query)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 10
|
||||
Id,
|
||||
ISNULL(Name, 'Sin Nombre') as Name,
|
||||
ISNULL(DniOrCuit, '') as DniOrCuit,
|
||||
Email, Phone, Address
|
||||
FROM Clients
|
||||
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
|
||||
ORDER BY Name";
|
||||
ISNULL(BillingName, Username) as Name,
|
||||
ISNULL(BillingTaxId, '') as DniOrCuit,
|
||||
Email, Phone, BillingAddress as Address
|
||||
FROM Users
|
||||
WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query
|
||||
ORDER BY BillingName";
|
||||
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
|
||||
}
|
||||
|
||||
// Asegurar existencia (Upsert)
|
||||
// Asegurar existencia (Upsert en la tabla Users)
|
||||
public async Task<int> EnsureClientExistsAsync(string name, string dni)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
|
||||
var existingId = await conn.ExecuteScalarAsync<int?>(
|
||||
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
|
||||
"SELECT Id FROM Users WHERE BillingTaxId = @Dni", new { Dni = dni });
|
||||
|
||||
if (existingId.HasValue)
|
||||
{
|
||||
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
|
||||
await conn.ExecuteAsync("UPDATE Users SET BillingName = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
|
||||
return existingId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Si no existe, creamos un usuario con rol Cliente (sin password por ahora, es solo para gestión de mostrador)
|
||||
var sql = @"
|
||||
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
|
||||
INSERT INTO Users (Username, Role, BillingName, BillingTaxId, PasswordHash, MustChangePassword)
|
||||
VALUES (@Username, 'Client', @Name, @Dni, 'N/A', 0);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
|
||||
// El username será el DNI para asegurar unicidad si no hay otro dato
|
||||
return await conn.QuerySingleAsync<int>(sql, new { Username = dni, Name = name, Dni = dni });
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
|
||||
// Obtener todos con estadísticas desde Users
|
||||
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
c.Id as id,
|
||||
ISNULL(c.Name, 'Sin Nombre') as name,
|
||||
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit,
|
||||
ISNULL(c.Email, 'Sin correo') as email,
|
||||
ISNULL(c.Phone, 'Sin teléfono') as phone,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent
|
||||
FROM Clients c
|
||||
ORDER BY c.Name";
|
||||
u.Id as id,
|
||||
ISNULL(u.BillingName, u.Username) as name,
|
||||
ISNULL(u.BillingTaxId, 'S/D') as dniOrCuit,
|
||||
ISNULL(u.Email, 'Sin correo') as email,
|
||||
ISNULL(u.Phone, 'Sin teléfono') as phone,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = u.Id) as totalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = u.Id), 0) as totalSpent
|
||||
FROM Users u
|
||||
WHERE Role IN ('Client', 'User') -- Mostramos tanto clientes puros como usuarios web
|
||||
ORDER BY name";
|
||||
return await conn.QueryAsync(sql);
|
||||
}
|
||||
|
||||
@@ -73,12 +77,12 @@ public class ClientRepository
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE Clients
|
||||
SET Name = @Name,
|
||||
DniOrCuit = @DniOrCuit,
|
||||
UPDATE Users
|
||||
SET BillingName = @Name,
|
||||
BillingTaxId = @DniOrCuit,
|
||||
Email = @Email,
|
||||
Phone = @Phone,
|
||||
Address = @Address
|
||||
BillingAddress = @Address
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, client);
|
||||
}
|
||||
@@ -88,21 +92,23 @@ public class ClientRepository
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested,
|
||||
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds,
|
||||
u.Id,
|
||||
ISNULL(u.BillingName, u.Username) as Name,
|
||||
u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id) as TotalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = u.Id), 0) as TotalInvested,
|
||||
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = u.Id) as LastAdDate,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id AND Status = 'Published') as ActiveAds,
|
||||
ISNULL((
|
||||
SELECT TOP 1 cat.Name
|
||||
FROM Listings l
|
||||
JOIN Categories cat ON l.CategoryId = cat.Id
|
||||
WHERE l.ClientId = c.Id
|
||||
WHERE l.ClientId = u.Id
|
||||
GROUP BY cat.Name
|
||||
ORDER BY COUNT(l.Id) DESC
|
||||
), 'N/A') as PreferredCategory
|
||||
FROM Clients c
|
||||
WHERE c.Id = @Id";
|
||||
FROM Users u
|
||||
WHERE u.Id = @Id";
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
|
||||
}
|
||||
|
||||
63
src/SIGCM.Infrastructure/Repositories/CouponRepository.cs
Normal file
63
src/SIGCM.Infrastructure/Repositories/CouponRepository.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,14 @@ public class ListingRepository : IListingRepository
|
||||
INSERT INTO Listings (
|
||||
CategoryId, OperationId, Title, Description, Price, Currency,
|
||||
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId,
|
||||
PublicationStartDate, IsFeatured, FeaturedExpiry, AllowContact, CouponCode, Origin
|
||||
)
|
||||
VALUES (
|
||||
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
||||
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId,
|
||||
@PublicationStartDate, @IsFeatured, @FeaturedExpiry, @AllowContact, @CouponCode, @Origin
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
@@ -101,11 +103,15 @@ public class ListingRepository : IListingRepository
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
|
||||
l.*,
|
||||
c.Name as CategoryName,
|
||||
p.Name as ParentCategoryName,
|
||||
cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
|
||||
u.Username as CashierName
|
||||
FROM Listings l
|
||||
LEFT JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
LEFT JOIN Categories p ON c.ParentId = p.Id
|
||||
LEFT JOIN Users cl ON l.ClientId = cl.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
WHERE l.Id = @Id;
|
||||
|
||||
@@ -142,10 +148,16 @@ public class ListingRepository : IListingRepository
|
||||
CreatedAt = listingData.CreatedAt,
|
||||
UserId = listingData.UserId,
|
||||
CategoryName = listingData.CategoryName,
|
||||
ParentCategoryName = listingData.ParentCategoryName,
|
||||
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
|
||||
PrintText = listingData.PrintText,
|
||||
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
|
||||
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
|
||||
PublicationStartDate = listingData.PublicationStartDate,
|
||||
ApprovedAt = listingData.ApprovedAt,
|
||||
IsFeatured = listingData.IsFeatured != null && Convert.ToBoolean(listingData.IsFeatured),
|
||||
FeaturedExpiry = listingData.FeaturedExpiry,
|
||||
AllowContact = listingData.AllowContact != null && Convert.ToBoolean(listingData.AllowContact),
|
||||
},
|
||||
Attributes = attributes,
|
||||
Images = images,
|
||||
@@ -162,6 +174,8 @@ public class ListingRepository : IListingRepository
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
WHERE l.Status = 'Published'
|
||||
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
|
||||
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())
|
||||
ORDER BY l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql);
|
||||
@@ -190,7 +204,7 @@ public class ListingRepository : IListingRepository
|
||||
|
||||
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
|
||||
{
|
||||
return await SearchFacetedAsync(query, categoryId, null);
|
||||
return await SearchFacetedAsync(query, categoryId, null, null, null, null, null, true);
|
||||
}
|
||||
|
||||
// Búsqueda Avanzada Facetada
|
||||
@@ -201,23 +215,24 @@ public class ListingRepository : IListingRepository
|
||||
DateTime? from = null,
|
||||
DateTime? to = null,
|
||||
string? origin = null,
|
||||
string? status = null)
|
||||
string? status = null,
|
||||
bool onlyActive = false)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
string sql = @"
|
||||
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
|
||||
SELECT l.*, c.Name as CategoryName, cl.BillingName as ClientName, cl.BillingTaxId as ClientDni,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
LEFT JOIN Users cl ON l.ClientId = cl.Id
|
||||
WHERE 1=1";
|
||||
|
||||
// --- FILTROS EXISTENTES ---
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.BillingTaxId = @ExactQuery OR cl.BillingName LIKE @Query)";
|
||||
parameters.Add("Query", $"%{query}%");
|
||||
parameters.Add("ExactQuery", query);
|
||||
}
|
||||
@@ -253,7 +268,16 @@ public class ListingRepository : IListingRepository
|
||||
parameters.Add("Status", status);
|
||||
}
|
||||
|
||||
sql += " ORDER BY l.CreatedAt DESC";
|
||||
// --- FILTRO DE VISIBILIDAD (Solo para búsquedas públicas) ---
|
||||
if (onlyActive)
|
||||
{
|
||||
// Solo avisos publicados y dentro de su rango de vigencia
|
||||
sql += @" AND l.Status = 'Published'
|
||||
AND (l.PublicationStartDate IS NULL OR l.PublicationStartDate <= GETUTCDATE())
|
||||
AND (l.PrintDaysCount = 0 OR DATEADD(day, l.PrintDaysCount, COALESCE(l.PublicationStartDate, l.ApprovedAt, l.CreatedAt)) >= GETUTCDATE())";
|
||||
}
|
||||
|
||||
sql += " ORDER BY l.IsFeatured DESC, l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql, parameters);
|
||||
}
|
||||
@@ -277,15 +301,28 @@ public class ListingRepository : IListingRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Avisos que NO están publicados ni rechazados ni borrados.
|
||||
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión.
|
||||
// Para este ejemplo, buscamos 'Pending'.
|
||||
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC");
|
||||
// Asumimos 'Pending'. Incluimos conteo de notas no leídas del usuario.
|
||||
var sql = @"
|
||||
SELECT l.*,
|
||||
(SELECT COUNT(*) FROM ListingNotes ln WHERE ln.ListingId = l.Id AND ln.IsFromModerator = 0 AND ln.IsRead = 0) as UnreadNotesCount
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Pending'
|
||||
ORDER BY l.CreatedAt ASC";
|
||||
return await conn.QueryAsync<Listing>(sql);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(int id, string status)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE Listings SET Status = @Status WHERE Id = @Id", new { Id = id, Status = status });
|
||||
var sql = @"
|
||||
UPDATE Listings
|
||||
SET Status = @Status,
|
||||
ApprovedAt = CASE
|
||||
WHEN (@Status = 'Published' OR @Status = 'Approved') AND ApprovedAt IS NULL THEN GETUTCDATE()
|
||||
ELSE ApprovedAt
|
||||
END
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
|
||||
}
|
||||
|
||||
public async Task<int> CountByCategoryIdAsync(int categoryId)
|
||||
@@ -562,10 +599,11 @@ public class ListingRepository : IListingRepository
|
||||
SELECT
|
||||
l.Id, l.CreatedAt as Date, l.Title,
|
||||
c.Name as Category, u.Username as Cashier, l.AdFee as Amount,
|
||||
l.Origin as Source
|
||||
l.Origin as Source, cl.BillingName as ClientName
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
LEFT JOIN Users cl ON l.ClientId = cl.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
AND (@UserId IS NULL OR l.UserId = @UserId)
|
||||
|
||||
@@ -46,8 +46,10 @@ public class UserRepository : IUserRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret);
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret,
|
||||
BillingName, BillingAddress, BillingTaxId, BillingTaxType, ClientId, Phone)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret,
|
||||
@BillingName, @BillingAddress, @BillingTaxId, @BillingTaxType, @ClientId, @Phone);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, user);
|
||||
}
|
||||
@@ -75,7 +77,9 @@ public class UserRepository : IUserRepository
|
||||
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
|
||||
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
|
||||
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin,
|
||||
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret
|
||||
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret,
|
||||
BillingName = @BillingName, BillingAddress = @BillingAddress, BillingTaxId = @BillingTaxId, BillingTaxType = @BillingTaxType,
|
||||
ClientId = @ClientId, Phone = @Phone
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, user);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public class AuthService : IAuthService
|
||||
// Si MFA está activo, no devolver token aún, pedir verificación
|
||||
if (user.IsMfaEnabled)
|
||||
{
|
||||
return new AuthResult { Success = true, RequiresMfa = true };
|
||||
return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
|
||||
}
|
||||
|
||||
// Éxito: Reiniciar intentos y generar token
|
||||
@@ -59,7 +59,8 @@ public class AuthService : IAuthService
|
||||
{
|
||||
Success = true,
|
||||
Token = _tokenService.GenerateToken(user),
|
||||
RequiresPasswordChange = user.MustChangePassword
|
||||
RequiresPasswordChange = user.MustChangePassword,
|
||||
UserId = user.Id
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ public class AuthService : IAuthService
|
||||
};
|
||||
|
||||
await _userRepo.CreateAsync(user);
|
||||
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
|
||||
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
|
||||
}
|
||||
|
||||
// Login mediante Google OAuth
|
||||
@@ -117,9 +118,9 @@ public class AuthService : IAuthService
|
||||
await _userRepo.UpdateAsync(user);
|
||||
}
|
||||
|
||||
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true };
|
||||
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true, UserId = user.Id };
|
||||
|
||||
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
|
||||
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user), UserId = user.Id };
|
||||
}
|
||||
catch (InvalidJwtException)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SIGCM.Infrastructure.Services;
|
||||
@@ -8,10 +9,12 @@ namespace SIGCM.Infrastructure.Services;
|
||||
public class PricingService
|
||||
{
|
||||
private readonly PricingRepository _repo;
|
||||
private readonly ICouponRepository _couponRepo;
|
||||
|
||||
public PricingService(PricingRepository repo)
|
||||
public PricingService(PricingRepository repo, ICouponRepository couponRepo)
|
||||
{
|
||||
_repo = repo;
|
||||
_couponRepo = couponRepo;
|
||||
}
|
||||
|
||||
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
|
||||
@@ -53,41 +56,45 @@ public class PricingService
|
||||
|
||||
currentCost += extraWordCost + specialCharCost;
|
||||
|
||||
// 4. Estilos (Negrita / Recuadro) - Se suman al precio unitario diario
|
||||
// 4. Estilos (Negrita / Recuadro / Destacado)
|
||||
if (request.IsBold) currentCost += pricing.BoldSurcharge;
|
||||
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
|
||||
|
||||
// Costo Destacado (Hardcoded por ahora o agregar a regla)
|
||||
decimal featuredSurcharge = 0;
|
||||
if (request.IsFeatured)
|
||||
{
|
||||
featuredSurcharge = 500m; // Valor ejemplo por día
|
||||
currentCost += featuredSurcharge;
|
||||
}
|
||||
|
||||
// 5. Multiplicar por Días
|
||||
decimal totalBeforeDiscount = currentCost * request.Days;
|
||||
|
||||
// 6. Motor de Promociones
|
||||
// 6. Motor de Promociones y Cupones
|
||||
var promotions = await _repo.GetActivePromotionsAsync();
|
||||
decimal totalDiscount = 0;
|
||||
List<string> appliedPromos = new();
|
||||
|
||||
// Lógica de promociones automáticas
|
||||
foreach (var promo in promotions)
|
||||
{
|
||||
// Filtro por Categoría
|
||||
if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue;
|
||||
|
||||
// Filtro por Cantidad de Días
|
||||
if (promo.MinDays > 0 && request.Days < promo.MinDays) continue;
|
||||
|
||||
// Filtro por Días de la Semana
|
||||
|
||||
// Verificar si aplica según el día de la semana
|
||||
if (!string.IsNullOrEmpty(promo.DaysOfWeek))
|
||||
{
|
||||
var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList();
|
||||
bool hitsDay = false;
|
||||
// Revisamos cada día que durará el aviso
|
||||
for (int i = 0; i < request.Days; i++)
|
||||
{
|
||||
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
|
||||
if (targetDays.Contains(currentDay)) hitsDay = true;
|
||||
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
|
||||
if (targetDays.Contains(currentDay)) hitsDay = true;
|
||||
}
|
||||
if (!hitsDay) continue; // No cae en ningún día de promo
|
||||
if (!hitsDay) continue;
|
||||
}
|
||||
|
||||
// Aplicar Descuento
|
||||
if (promo.DiscountPercentage > 0)
|
||||
{
|
||||
decimal discountVal = totalBeforeDiscount * (promo.DiscountPercentage / 100m);
|
||||
@@ -101,16 +108,38 @@ public class PricingService
|
||||
}
|
||||
}
|
||||
|
||||
// Lógica de Cupón
|
||||
if (!string.IsNullOrEmpty(request.CouponCode))
|
||||
{
|
||||
var coupon = await _couponRepo.GetByCodeAsync(request.CouponCode);
|
||||
if (coupon != null && (!coupon.ExpiryDate.HasValue || coupon.ExpiryDate > DateTime.UtcNow)
|
||||
&& (!coupon.MaxUsages.HasValue || coupon.UsageCount < coupon.MaxUsages))
|
||||
{
|
||||
if (coupon.DiscountType == "Percentage")
|
||||
{
|
||||
decimal val = totalBeforeDiscount * (coupon.DiscountValue / 100m);
|
||||
totalDiscount += val;
|
||||
appliedPromos.Add($"CUPÓN {coupon.Code} (-{coupon.DiscountValue}%)");
|
||||
}
|
||||
else if (coupon.DiscountType == "Fixed")
|
||||
{
|
||||
totalDiscount += coupon.DiscountValue;
|
||||
appliedPromos.Add($"CUPÓN {coupon.Code} (-${coupon.DiscountValue})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
||||
BaseCost = pricing.BasePrice * request.Days,
|
||||
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
|
||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
|
||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
|
||||
Discount = totalDiscount,
|
||||
WordCount = realWordCount,
|
||||
SpecialCharCount = specialCharCount,
|
||||
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
|
||||
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost}). {string.Join(", ", appliedPromos)}",
|
||||
AppliedPromotion = string.Join(", ", appliedPromos)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user