Feat: Cambios Varios 2

This commit is contained in:
2026-01-05 10:30:04 -03:00
parent 8bc1308bc5
commit 0fa77e4a98
184 changed files with 11098 additions and 6348 deletions

View File

@@ -1,4 +1,4 @@
using System.Data;
// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
using Dapper;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
@@ -17,7 +17,7 @@ public class ListingRepository : IListingRepository
_connectionFactory = connectionFactory;
}
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes)
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null)
{
using var conn = _connectionFactory.CreateConnection();
conn.Open();
@@ -29,12 +29,12 @@ public class ListingRepository : IListingRepository
INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -52,6 +52,34 @@ public class ListingRepository : IListingRepository
}
}
if (listing.ImagesToClone != null && listing.ImagesToClone.Any())
{
foreach (var url in listing.ImagesToClone)
{
// Validamos que la URL sea válida antes de insertar
if (string.IsNullOrEmpty(url)) continue;
var sqlImg = @"INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder)
VALUES (@listingId, @url, 0, 0)";
// Usamos el ID del nuevo aviso (listingId) y la URL del viejo
await conn.ExecuteAsync(sqlImg, new { listingId, url }, transaction);
}
}
if (payments != null && payments.Any())
{
var sqlPayment = @"
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate)
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate)";
foreach (var pay in payments)
{
pay.ListingId = listingId;
await conn.ExecuteAsync(sqlPayment, pay, transaction);
}
}
transaction.Commit();
return listingId;
}
@@ -71,55 +99,57 @@ public class ListingRepository : IListingRepository
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
// Mejoramos el SQL para asegurar que los nulos se conviertan en 0 (false) desde el motor
var sql = @"
SELECT
l.Id, l.CategoryId, l.OperationId, l.Title, l.Description, l.Price, l.AdFee,
l.Currency, l.CreatedAt, l.Status, l.UserId, l.PrintText, l.PrintDaysCount,
ISNULL(l.IsBold, 0) as IsBold,
ISNULL(l.IsFrame, 0) as IsFrame,
l.PrintFontSize, l.PrintAlignment, l.ClientId,
c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni
FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
WHERE l.Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
";
SELECT
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit 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 Users u ON l.UserId = u.Id
WHERE l.Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
SELECT * FROM Payments WHERE ListingId = @Id;
";
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listing == null) return null;
var listingData = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listingData == null) return null;
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
var images = await multi.ReadAsync<ListingImage>();
var payments = await multi.ReadAsync<Payment>();
return new ListingDetail
{
Listing = new Listing
{
Id = (int)listing.Id,
Title = listing.Title,
Description = listing.Description,
Price = listing.Price,
AdFee = listing.AdFee,
Status = listing.Status,
CreatedAt = listing.CreatedAt,
PrintText = listing.PrintText,
IsBold = Convert.ToBoolean(listing.IsBold),
IsFrame = Convert.ToBoolean(listing.IsFrame),
PrintDaysCount = listing.PrintDaysCount,
CategoryName = listing.CategoryName
Id = (int)listingData.Id,
CategoryId = (int)(listingData.CategoryId ?? 0),
OperationId = (int)(listingData.OperationId ?? 0),
Title = listingData.Title ?? "",
Description = listingData.Description ?? "",
Price = listingData.Price ?? 0m,
AdFee = listingData.AdFee ?? 0m,
Status = listingData.Status ?? "Draft",
CreatedAt = listingData.CreatedAt,
UserId = listingData.UserId,
CategoryName = listingData.CategoryName,
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
PrintText = listingData.PrintText,
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
},
Attributes = attributes,
Images = images
Images = images,
Payments = payments
};
}
@@ -127,85 +157,100 @@ public class ListingRepository : IListingRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT TOP 20 l.*,
SELECT l.*, c.Name as CategoryName,
(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
WHERE l.Status = 'Published'
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
}
// Implementación de incremento
public async Task IncrementViewCountAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE Listings SET ViewCount = ViewCount + 1 WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<Listing>> GetByUserIdAsync(int userId)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT l.*, c.Name as CategoryName,
(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
WHERE l.UserId = @UserId
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql, new { UserId = userId });
}
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
{
return await SearchFacetedAsync(query, categoryId, null);
}
// Búsqueda Avanzada Facetada
public async Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes)
public async Task<IEnumerable<Listing>> SearchFacetedAsync(
string? query,
int? categoryId,
Dictionary<string, string>? attributes,
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null)
{
using var conn = _connectionFactory.CreateConnection();
var parameters = new DynamicParameters();
string sql;
// Construcción Dinámica de la Query con CTE
if (categoryId.HasValue && categoryId.Value > 0)
{
sql = @"
WITH CategoryTree AS (
SELECT Id FROM Categories WHERE Id = @CategoryId
UNION ALL
SELECT c.Id FROM Categories c
INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
)
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'
AND l.CategoryId IN (SELECT Id FROM CategoryTree)";
string sql = @"
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit 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
WHERE 1=1";
parameters.Add("CategoryId", categoryId);
}
else
{
// Sin filtro de categoría (o todas)
sql = @"
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'";
}
// Filtro de Texto
// --- FILTROS EXISTENTES ---
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
parameters.Add("Query", $"%{query}%");
parameters.Add("ExactQuery", query);
}
// Filtros de Atributos (Igual que antes)
if (attributes != null && attributes.Any())
if (categoryId.HasValue && categoryId.Value > 0)
{
int i = 0;
foreach (var attr in attributes)
{
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
string paramName = $"@Val{i}";
string paramKey = $"@Key{i}";
sql += " AND l.CategoryId = @CategoryId";
parameters.Add("CategoryId", categoryId);
}
sql += $@" AND EXISTS (
SELECT 1 FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = l.Id
AND ad.Name = {paramKey}
AND lav.Value LIKE {paramName}
)";
if (from.HasValue)
{
sql += " AND l.CreatedAt >= @From";
parameters.Add("From", from.Value.Date);
}
parameters.Add($"Val{i}", $"%{attr.Value}%");
parameters.Add($"Key{i}", attr.Key);
i++;
}
if (to.HasValue)
{
sql += " AND l.CreatedAt <= @To";
parameters.Add("To", to.Value.Date.AddDays(1).AddSeconds(-1));
}
if (!string.IsNullOrEmpty(origin) && origin != "All")
{
sql += " AND l.Origin = @Origin";
parameters.Add("Origin", origin);
}
// --- FILTRO DE ESTADO ---
if (!string.IsNullOrEmpty(status))
{
sql += " AND l.Status = @Status";
parameters.Add("Status", status);
}
sql += " ORDER BY l.CreatedAt DESC";
@@ -317,11 +362,11 @@ public class ListingRepository : IListingRepository
AND Status = 'Published'";
var kpis = await conn.QueryFirstOrDefaultAsync(kpisSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.RevenueToday = kpis != null ? (decimal)kpis.RevenueToday : 0;
stats.AdsToday = kpis != null ? (int)kpis.AdsToday : 0;
stats.RevenueToday = kpis?.RevenueToday ?? 0;
stats.AdsToday = kpis?.AdsToday ?? 0;
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
// 2. Ocupación (basada en el último día del rango)
// 2. Occupation (based on the last day of the range)
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
// 3. Tendencia del periodo
@@ -357,6 +402,142 @@ public class ListingRepository : IListingRepository
return stats;
}
public async Task<AdvancedAnalyticsDto> GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
var analytics = new AdvancedAnalyticsDto();
// 1. Calcular Periodo Anterior para comparación
var duration = endDate - startDate;
var prevStart = startDate.Add(-duration);
var prevEnd = startDate.AddSeconds(-1);
// 2. KPIs del Periodo Actual
var currentKpiSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
COUNT(Id) as Ads
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'";
var currentKpis = await conn.QueryFirstOrDefaultAsync(currentKpiSql, new { Start = startDate.Date, End = endDate.Date });
analytics.TotalRevenue = currentKpis?.Revenue ?? 0;
analytics.TotalAds = currentKpis?.Ads ?? 0;
// 3. KPIs del Periodo Anterior
var prevKpiSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
COUNT(Id) as Ads
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @PrevStart AND @PrevEnd
AND Status = 'Published'";
var prevKpis = await conn.QueryFirstOrDefaultAsync(prevKpiSql, new { PrevStart = prevStart.Date, PrevEnd = prevEnd.Date });
analytics.PreviousPeriodRevenue = prevKpis?.Revenue ?? 0;
analytics.PreviousPeriodAds = prevKpis?.Ads ?? 0;
// 4. Calcular Crecimiento
if (analytics.PreviousPeriodRevenue > 0)
analytics.RevenueGrowth = (double)((analytics.TotalRevenue - analytics.PreviousPeriodRevenue) / analytics.PreviousPeriodRevenue) * 100;
if (analytics.PreviousPeriodAds > 0)
analytics.AdsGrowth = (double)(analytics.TotalAds - analytics.PreviousPeriodAds) / analytics.PreviousPeriodAds * 100;
// 5. Distribución de Pagos (Real)
var paymentsSql = @"
SELECT
p.PaymentMethod as Method,
SUM(p.Amount + p.Surcharge) as Total,
COUNT(p.Id) as Count
FROM Payments p
INNER JOIN Listings l ON p.ListingId = l.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
GROUP BY p.PaymentMethod";
analytics.PaymentsDistribution = (await conn.QueryAsync<PaymentMethodStat>(paymentsSql, new { Start = startDate.Date, End = endDate.Date })).ToList();
// 6. Rendimiento por Categoría
var categorySql = @"
SELECT
c.Name as CategoryName,
SUM(l.AdFee) as Revenue,
COUNT(l.Id) as AdsCount
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
GROUP BY c.Name
ORDER BY Revenue DESC";
var catPerf = (await conn.QueryAsync<CategoryPerformanceStat>(categorySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
foreach (var cp in catPerf)
{
cp.Share = analytics.TotalRevenue > 0 ? (double)(cp.Revenue / analytics.TotalRevenue) * 100 : 0;
}
analytics.CategoryPerformance = catPerf;
// 7. Análisis Horario (Peak Hours)
var hourlySql = @"
SELECT
DATEPART(HOUR, CreatedAt) as Hour,
COUNT(Id) as Count
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY DATEPART(HOUR, CreatedAt)
ORDER BY Hour";
analytics.HourlyActivity = (await conn.QueryAsync<HourlyStat>(hourlySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
// 8. Tendencia Diaria
var dailySql = @"
SELECT
FORMAT(CreatedAt, 'dd/MM') as Day,
SUM(AdFee) as Amount
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
ORDER BY CAST(CreatedAt AS DATE) ASC";
analytics.DailyTrends = (await conn.QueryAsync<DailyRevenue>(dailySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
var sourceSql = @"
SELECT
Origin,
COUNT(Id) as Count
FROM Listings
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'
GROUP BY Origin";
var sources = await conn.QueryAsync<dynamic>(sourceSql, new { Start = startDate.Date, End = endDate.Date });
int total = 0;
int web = 0;
int mostrador = 0;
foreach (var s in sources)
{
if (s.Origin == "Web") web = (int)s.Count;
if (s.Origin == "Mostrador") mostrador = (int)s.Count;
total += (int)s.Count;
}
analytics.SourceMix = new SourceMixDto
{
MostradorCount = mostrador,
WebCount = web,
MostradorPercent = total > 0 ? (mostrador * 100.0 / total) : 0,
WebPercent = total > 0 ? (web * 100.0 / total) : 0
};
return analytics;
}
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
@@ -364,18 +545,12 @@ public class ListingRepository : IListingRepository
// Filtramos tanto la recaudación como los pendientes por el rango seleccionado
var sql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as MyRevenue,
COUNT(Id) as MyAdsCount,
(SELECT COUNT(1) FROM Listings
WHERE UserId = @UserId AND Status = 'Pending'
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End) as MyPendingAds
FROM Listings
WHERE UserId = @UserId
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'";
(SELECT ISNULL(SUM(Amount + Surcharge), 0) FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id
WHERE l.UserId = @UserId AND CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published') as MyRevenue,
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND Status = 'Pending') as MyPendingAds,
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published') as MyAdsCount";
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql,
new { UserId = userId, Start = startDate.Date, End = endDate.Date });
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql, new { UserId = userId, Start = startDate.Date, End = endDate.Date });
}
public async Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null)
@@ -383,30 +558,83 @@ public class ListingRepository : IListingRepository
using var conn = _connectionFactory.CreateConnection();
var report = new GlobalReportDto { FromDate = start, ToDate = end };
// Filtro inteligente: Si @UserId es NULL, devuelve todo. Si no, filtra por ese usuario.
var sql = @"
SELECT
l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId) -- <--- FILTRO DINÁMICO
ORDER BY l.CreatedAt ASC";
var items = await conn.QueryAsync<ReportItemDto>(sql, new
{
Start = start.Date,
End = end.Date,
UserId = userId // Dapper pasará null si el parámetro es null
});
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
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId)
ORDER BY l.CreatedAt ASC";
var items = await conn.QueryAsync<ReportItemDto>(sql, new { Start = start.Date, End = end.Date, UserId = userId });
report.Items = items.ToList();
report.TotalRevenue = report.Items.Sum(x => x.Amount);
report.TotalAds = report.Items.Count;
// TOTALES FÍSICOS (Solo lo que entró por caja)
var totalsSql = @"
SELECT
p.PaymentMethod,
SUM(p.Amount + p.Surcharge) as Total
FROM Payments p
INNER JOIN Listings l ON p.ListingId = l.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND l.Origin = 'Mostrador'
AND (@UserId IS NULL OR l.UserId = @UserId)
GROUP BY p.PaymentMethod";
var paymentTotals = await conn.QueryAsync<dynamic>(totalsSql, new { Start = start.Date, End = end.Date, UserId = userId });
// Reiniciar totales para el reporte
report.TotalCash = 0; report.TotalDebit = 0; report.TotalCredit = 0; report.TotalTransfer = 0;
foreach (var p in paymentTotals)
{
string method = p.PaymentMethod;
decimal total = (decimal)p.Total;
switch (method)
{
case "Cash": report.TotalCash = total; break;
case "Debit": report.TotalDebit = total; break;
case "Credit": report.TotalCredit = total; break;
case "Transfer": report.TotalTransfer = total; break;
}
}
report.TotalRevenue = report.TotalCash + report.TotalDebit + report.TotalCredit + report.TotalTransfer;
return report;
}
public async Task AddPaymentAsync(Payment payment)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate, ExternalReference, ExternalId, Status)
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate, @ExternalReference, @ExternalId, @Status)";
await conn.ExecuteAsync(sql, payment);
}
public async Task<IEnumerable<dynamic>> GetActiveCashiersAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Traemos usuarios que tengan el rol adecuado para filtrar en el historial
var sql = @"SELECT Id, Username FROM Users
WHERE Role IN ('Cajero', 'Admin')
AND IsActive = 1
ORDER BY Username ASC";
return await conn.QueryAsync(sql);
}
public async Task UpdateOverlayStatusAsync(int id, int userId, string? status)
{
using var conn = _connectionFactory.CreateConnection();
// Seguridad: Filtramos por Id del aviso Y Id del usuario dueño
var sql = "UPDATE Listings SET OverlayStatus = @status WHERE Id = @id AND UserId = @userId";
await conn.ExecuteAsync(sql, new { id, userId, status });
}
}