Feat: Cambios Varios

This commit is contained in:
2025-12-23 15:12:57 -03:00
parent 32663e6324
commit 8bc1308bc5
58 changed files with 4080 additions and 663 deletions

View File

@@ -9,10 +9,12 @@ namespace SIGCM.API.Controllers;
public class CategoriesController : ControllerBase
{
private readonly ICategoryRepository _repository;
private readonly IListingRepository _listingRepo;
public CategoriesController(ICategoryRepository repository)
public CategoriesController(ICategoryRepository repository, IListingRepository listingRepo)
{
_repository = repository;
_listingRepo = listingRepo;
}
[HttpGet]
@@ -33,6 +35,14 @@ public class CategoriesController : ControllerBase
[HttpPost]
public async Task<IActionResult> Create(Category category)
{
// Regla: No crear hijos en padres con avisos
if (category.ParentId.HasValue)
{
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
if (adsCount > 0)
return BadRequest($"El rubro padre contiene {adsCount} avisos. Muévalos antes de crear sub-rubros.");
}
var id = await _repository.AddAsync(category);
category.Id = id;
return CreatedAtAction(nameof(GetById), new { id }, category);
@@ -42,6 +52,15 @@ public class CategoriesController : ControllerBase
public async Task<IActionResult> Update(int id, Category category)
{
if (id != category.Id) return BadRequest();
// Regla: Drag & Drop (Mover categoría dentro de otra)
if (category.ParentId.HasValue)
{
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
if (adsCount > 0)
return BadRequest($"El destino contiene {adsCount} avisos. No puede aceptar sub-rubros.");
}
await _repository.UpdateAsync(category);
return NoContent();
}
@@ -53,6 +72,7 @@ public class CategoriesController : ControllerBase
return NoContent();
}
// --- Endpoints de Operaciones ---
[HttpGet("{id}/operations")]
public async Task<IActionResult> GetOperations(int id)
{
@@ -73,5 +93,32 @@ public class CategoriesController : ControllerBase
await _repository.RemoveOperationAsync(id, operationId);
return NoContent();
}
}
// --- Endpoints Avanzados ---
[HttpPost("merge")]
public async Task<IActionResult> Merge([FromBody] MergeRequest request)
{
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId);
return Ok(new { message = "Fusión completada." });
}
[HttpPost("move-content")]
public async Task<IActionResult> MoveContent([FromBody] MergeRequest request)
{
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
// Regla: No mover avisos a una categoría que tiene hijos (Padre)
bool targetHasChildren = await _repository.HasChildrenAsync(request.TargetId);
if (targetHasChildren)
{
return BadRequest("El destino tiene sub-rubros. No puede contener avisos directos.");
}
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId);
return Ok(new { message = "Avisos movidos correctamente." });
}
public class MergeRequest { public int SourceId { get; set; } public int TargetId { get; set; } }
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ClientsController : ControllerBase
{
private readonly ClientRepository _repo;
public ClientsController(ClientRepository repo)
{
_repo = repo;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(new List<object>());
var clients = await _repo.SearchAsync(q);
return Ok(clients);
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var clients = await _repo.GetAllWithStatsAsync();
return Ok(clients);
}
[HttpGet("{id}/history")]
public async Task<IActionResult> GetHistory(int id)
{
var history = await _repo.GetClientHistoryAsync(id);
return Ok(history);
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
@@ -10,15 +12,28 @@ namespace SIGCM.API.Controllers;
public class ListingsController : ControllerBase
{
private readonly IListingRepository _repository;
public ListingsController(IListingRepository repository)
private readonly ClientRepository _clientRepo;
private readonly AuditRepository _auditRepo;
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
{
_repository = repository;
_clientRepo = clientRepo;
_auditRepo = auditRepo;
}
[HttpPost]
public async Task<IActionResult> Create(CreateListingDto dto)
{
int? clientId = null;
// Si viene información de cliente, aseguramos que exista en BD
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
{
// Usamos "Consumidor Final" si no hay nombre pero hay DNI, o el nombre provisto
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
}
var listing = new Listing
{
CategoryId = dto.CategoryId,
@@ -60,4 +75,63 @@ public class ListingsController : ControllerBase
if (listingDetail == null) return NotFound();
return Ok(listingDetail);
}
// Búsqueda Facetada (POST para enviar diccionario complejo)
[HttpPost("search")]
public async Task<IActionResult> Search([FromBody] SearchRequest request)
{
var results = await _repository.SearchFacetedAsync(request.Query, request.CategoryId, request.Filters);
return Ok(results);
}
// Moderación: Obtener pendientes
[HttpGet("pending")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> GetPending()
{
var pending = await _repository.GetPendingModerationAsync();
return Ok(pending);
}
// Moderación: Cambiar estado
[HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
{
// 1. Obtener ID del usuario desde el Token JWT
var userIdStr = User.FindFirst("Id")?.Value;
int? currentUserId = !string.IsNullOrEmpty(userIdStr) ? int.Parse(userIdStr) : null;
// 2. Actualizar el estado del aviso
await _repository.UpdateStatusAsync(id, status);
// 3. Registrar en Auditoría (Si tenemos el repositorio inyectado)
if (currentUserId.HasValue)
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId.Value,
Action = status == "Published" ? "Aprobar" : "Rechazar",
EntityId = id,
EntityType = "Listing",
Details = $"El usuario cambió el estado del aviso #{id} a {status}"
});
}
return Ok();
}
public class SearchRequest
{
public string? Query { get; set; }
public int? CategoryId { get; set; }
public Dictionary<string, string>? Filters { get; set; }
}
[HttpGet("pending/count")]
public async Task<IActionResult> GetPendingCount()
{
var count = await _repository.GetPendingCountAsync();
return Ok(count);
}
}

View File

@@ -0,0 +1,122 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Infrastructure.Services;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Cajero,Admin")]
public class ReportsController : ControllerBase
{
private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo)
{
_listingRepo = listingRepo;
_auditRepo = auditRepo;
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.Date;
var end = to ?? DateTime.UtcNow.Date;
var stats = await _listingRepo.GetDashboardStatsAsync(start, end);
return Ok(stats);
}
[HttpGet("sales-by-category")]
public async Task<IActionResult> GetSalesByCategory([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.AddMonths(-1);
var end = to ?? DateTime.UtcNow;
var data = await _listingRepo.GetSalesByRootCategoryAsync(start, end);
var totalAmount = data.Sum(x => x.TotalSales);
if (totalAmount > 0)
{
foreach (var item in data)
item.Percentage = Math.Round((item.TotalSales / totalAmount) * 100, 2);
}
return Ok(data);
}
[HttpGet("audit")]
public async Task<IActionResult> GetAuditLogs()
{
// Obtenemos los últimos 100 eventos
var logs = await _auditRepo.GetRecentLogsAsync(100);
return Ok(logs);
}
[HttpGet("cashier")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCashierDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
int userId = int.Parse(userIdClaim);
// Si no vienen fechas, usamos hoy por defecto
var start = from ?? DateTime.UtcNow.Date;
var end = to ?? DateTime.UtcNow.Date;
var stats = await _listingRepo.GetCashierStatsAsync(userId, start, end);
return Ok(stats);
}
[HttpGet("export-cierre")]
[Authorize(Roles = "Admin,Cajero")]
public async Task<IActionResult> ExportCierre([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] int? userId) // <--- Agregamos userId opcional
{
var userIdClaim = User.FindFirst("Id")?.Value;
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
// SEGURIDAD:
// Si es Cajero, ignoramos lo que envíe y forzamos su propio ID.
// Si es Admin, usamos el userId que venga en la URL (si viene).
int? targetUserId = (userRole == "Cajero") ? int.Parse(userIdClaim!) : userId;
// 1. Obtener datos filtrados
var data = await _listingRepo.GetDetailedReportAsync(from, to, targetUserId);
// 2. Título Dinámico: Si hay un filtro de usuario, no es un cierre global.
string title = (targetUserId.HasValue)
? $"REPORTE DE ACTIVIDAD: {data.Items.FirstOrDefault()?.Cashier ?? "Usuario Sin Cargas"}"
: "CIERRE GLOBAL DE JORNADA";
// 3. Generar PDF
var pdfBytes = ReportGenerator.GenerateSalesPdf(data, title);
string fileName = targetUserId.HasValue ? $"Actividad_Caja_{targetUserId}" : "Cierre_Global";
return File(pdfBytes, "application/pdf", $"{fileName}_{from:yyyyMMdd}.pdf");
}
[HttpGet("audit/user/{userId}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAuditLogsByUser(int userId)
{
var logs = await _auditRepo.GetLogsByUserAsync(userId);
return Ok(logs);
}
[HttpGet("cashier-transactions")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCashierTransactions()
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
int userId = int.Parse(userIdClaim);
// Usamos el repositorio para traer los avisos de hoy de este usuario
var transactions = await _listingRepo.GetDetailedReportAsync(DateTime.UtcNow, DateTime.UtcNow, userId);
return Ok(transactions);
}
}

View File

@@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using SIGCM.Infrastructure;
using SIGCM.Infrastructure.Data;
using QuestPDF.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
QuestPDF.Settings.License = LicenseType.Community;
// 1. Agregar servicios al contenedor.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>

View File

@@ -14,6 +14,8 @@ public class CreateListingDto
public string? PrintFontSize { get; set; }
public string? PrintAlignment { get; set; }
public int PrintDaysCount { get; set; }
public string? ClientName { get; set; }
public string? ClientDni { get; set; }
}
public class ListingDto : CreateListingDto

View File

@@ -0,0 +1,14 @@
namespace SIGCM.Domain.Entities;
public class AuditLog
{
public int Id { get; set; }
public int UserId { get; set; }
public required string Action { get; set; }
public int? EntityId { get; set; }
public string? EntityType { get; set; }
public string? Details { get; set; }
public DateTime CreatedAt { get; set; }
// Propiedad auxiliar para el Join
public string? Username { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Domain.Entities;
public class Client
{
public int Id { get; set; }
public required string Name { get; set; }
public required string DniOrCuit { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
}

View File

@@ -8,19 +8,23 @@ public class Listing
public required string Title { get; set; }
public string? Description { get; set; }
public decimal Price { get; set; }
public decimal AdFee { get; set; }
public string Currency { get; set; } = "ARS";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string Status { get; set; } = "Draft";
public int? UserId { get; set; }
public int? ClientId { get; set; }
// Propiedades para impresión
public string? PrintText { get; set; }
public DateTime? PrintStartDate { get; set; }
public int PrintDaysCount { get; set; }
public bool IsBold { get; set; }
public bool IsFrame { get; set; }
public string PrintFontSize { get; set; } = "normal";
public string PrintAlignment { get; set; } = "left";
// Propiedades auxiliares (no están en la tabla Listings, vienen de Joins/Subqueries)
// Propiedades auxiliares
public string? CategoryName { get; set; }
public string? MainImageUrl { get; set; }
}

View File

@@ -1,6 +1,7 @@
namespace SIGCM.Domain.Interfaces;
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface ICategoryRepository
{
Task<IEnumerable<Category>> GetAllAsync();
@@ -12,4 +13,6 @@ public interface ICategoryRepository
Task<IEnumerable<Operation>> GetOperationsAsync(int categoryId);
Task AddOperationAsync(int categoryId, int operationId);
Task RemoveOperationAsync(int categoryId, int operationId);
}
Task MergeCategoriesAsync(int sourceId, int targetId);
Task<bool> HasChildrenAsync(int categoryId);
}

View File

@@ -1,5 +1,6 @@
using SIGCM.Domain.Models;
using SIGCM.Domain.Entities;
using SIGCM.Application.DTOs;
namespace SIGCM.Domain.Interfaces;
@@ -9,6 +10,24 @@ public interface IListingRepository
Task<Listing?> GetByIdAsync(int id);
Task<ListingDetail?> GetDetailByIdAsync(int id);
Task<IEnumerable<Listing>> GetAllAsync();
Task<int> CountByCategoryIdAsync(int categoryId);
Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId);
// Búsqueda Simple y Facetada
Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId);
Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes);
// Impresión
Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime date);
// Moderación
Task<IEnumerable<Listing>> GetPendingModerationAsync();
Task UpdateStatusAsync(int id, string status);
Task<int> GetPendingCountAsync();
// Estadísticas
Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate);
Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate);
Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate);
Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null);
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM.Domain.Models;
public class CashierDashboardDto
{
public decimal MyRevenue { get; set; }
public int MyAdsCount { get; set; }
public int MyPendingAds { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM.Application.DTOs;
public class CategorySalesReportDto
{
public int CategoryId { get; set; }
public required string CategoryName { get; set; }
public decimal TotalSales { get; set; }
public int AdCount { get; set; }
public decimal Percentage { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace SIGCM.Domain.Models;
public class DashboardStats
{
public decimal RevenueToday { get; set; }
public int AdsToday { get; set; }
public decimal TicketAverage { get; set; }
public double PaperOccupation { get; set; }
public List<DailyRevenue> WeeklyTrend { get; set; } = new();
public List<ChannelStat> ChannelMix { get; set; } = new();
}
public class DailyRevenue
{
public string Day { get; set; } = "";
public decimal Amount { get; set; }
}
public class ChannelStat
{
public string Name { get; set; } = "";
public int Value { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace SIGCM.Domain.Models;
public class GlobalReportDto
{
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
public DateTime FromDate { get; set; }
public DateTime ToDate { get; set; }
public decimal TotalRevenue { get; set; }
public int TotalAds { get; set; }
public List<ReportItemDto> Items { get; set; } = new();
}
public class ReportItemDto
{
public int Id { get; set; }
public DateTime Date { get; set; }
public string Title { get; set; } = "";
public string Category { get; set; } = "";
public string Cashier { get; set; } = "";
public decimal Amount { get; set; }
}

View File

@@ -23,6 +23,8 @@ public static class DependencyInjection
services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<PricingRepository>();
services.AddScoped<PricingService>();
services.AddScoped<ClientRepository>();
services.AddScoped<AuditRepository>();
return services;
}
}

View File

@@ -0,0 +1,40 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class AuditRepository
{
private readonly IDbConnectionFactory _db;
public AuditRepository(IDbConnectionFactory db) => _db = db;
public async Task AddLogAsync(AuditLog log)
{
using var conn = _db.CreateConnection();
var sql = @"INSERT INTO AuditLogs (UserId, Action, EntityId, EntityType, Details)
VALUES (@UserId, @Action, @EntityId, @EntityType, @Details)";
await conn.ExecuteAsync(sql, log);
}
public async Task<IEnumerable<AuditLog>> GetRecentLogsAsync(int limit = 50)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT TOP (@Limit) a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { Limit = limit });
}
public async Task<IEnumerable<AuditLog>> GetLogsByUserAsync(int userId, int limit = 20)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT TOP (@Limit) a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
WHERE a.UserId = @UserId
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
}
}

View File

@@ -74,11 +74,11 @@ public class CategoryRepository : ICategoryRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = "INSERT INTO CategoryOperations (CategoryId, OperationId) VALUES (@CategoryId, @OperationId)";
try
try
{
await conn.ExecuteAsync(sql, new { CategoryId = categoryId, OperationId = operationId });
}
catch (SqlException)
catch (SqlException)
{
// Ignore duplicate key errors if it already exists
}
@@ -88,8 +88,59 @@ public class CategoryRepository : ICategoryRepository
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
new { CategoryId = categoryId, OperationId = operationId });
}
public async Task MergeCategoriesAsync(int sourceId, int targetId)
{
using var conn = _connectionFactory.CreateConnection();
conn.Open();
using var transaction = conn.BeginTransaction();
try
{
// 1. Mover Avisos
await conn.ExecuteAsync(
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 2. Mover Subcategorías (Hijos)
await conn.ExecuteAsync(
"UPDATE Categories SET ParentId = @TargetId WHERE ParentId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 3. Mover Definiciones de Atributos
await conn.ExecuteAsync(
"UPDATE AttributeDefinitions SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 4. Mover Operaciones (evitar duplicados con try/catch o lógica compleja, aquí simplificamos moviendo y borrando)
// Borramos las del source de la tabla intermedia
await conn.ExecuteAsync(
"DELETE FROM CategoryOperations WHERE CategoryId = @SourceId",
new { SourceId = sourceId }, transaction);
// 5. Borrar Categoría Fuente
await conn.ExecuteAsync(
"DELETE FROM Categories WHERE Id = @SourceId",
new { SourceId = sourceId }, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task<bool> HasChildrenAsync(int categoryId)
{
using var conn = _connectionFactory.CreateConnection();
// SELECT 1 es más rápido que COUNT
var result = await conn.ExecuteScalarAsync<int?>("SELECT TOP 1 1 FROM Categories WHERE ParentId = @Id", new { Id = categoryId });
return result.HasValue;
}
}

View File

@@ -0,0 +1,71 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ClientRepository
{
private readonly IDbConnectionFactory _db;
public ClientRepository(IDbConnectionFactory db)
{
_db = db;
}
// Búsqueda inteligente por Nombre O DNI
public async Task<IEnumerable<Client>> SearchAsync(string query)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP 10 * FROM Clients
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
ORDER BY Name";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
}
// Buscar o Crear (Upsert) al guardar el aviso
public async Task<int> EnsureClientExistsAsync(string name, string dni)
{
using var conn = _db.CreateConnection();
// 1. Buscamos por DNI/CUIT (es el identificador único más fiable)
var existingId = await conn.ExecuteScalarAsync<int?>(
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
if (existingId.HasValue)
{
// Opcional: Actualizar nombre si cambió
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
return existingId.Value;
}
else
{
// Crear nuevo
var sql = @"
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
}
}
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as TotalAds,
(SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id) as TotalSpent
FROM Clients c
ORDER BY c.Name";
return await conn.QueryAsync(sql);
}
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<Listing>(
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
new { Id = clientId });
}
}

View File

@@ -3,6 +3,7 @@ using Dapper;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Domain.Models;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
@@ -28,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
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -67,30 +68,56 @@ public class ListingRepository : IListingRepository
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
}
public async Task<SIGCM.Domain.Models.ListingDetail?> GetDetailByIdAsync(int id)
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 * FROM Listings WHERE Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
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 * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
";
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
var listing = await multi.ReadSingleOrDefaultAsync<Listing>();
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listing == null) return null;
var attributes = await multi.ReadAsync<SIGCM.Domain.Models.ListingAttributeValueWithName>();
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
var images = await multi.ReadAsync<ListingImage>();
return new SIGCM.Domain.Models.ListingDetail
return new ListingDetail
{
Listing = listing,
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
},
Attributes = attributes,
Images = images
};
@@ -99,37 +126,86 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> GetAllAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Subquery para obtener la imagen principal
var sql = @"
SELECT TOP 20 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'
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
}
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)
{
using var conn = _connectionFactory.CreateConnection();
var 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 1=1";
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)";
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
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
parameters.Add("Query", $"%{query}%");
}
if (categoryId.HasValue)
// Filtros de Atributos (Igual que antes)
if (attributes != null && attributes.Any())
{
sql += " AND l.CategoryId = @CategoryId";
parameters.Add("CategoryId", categoryId);
int i = 0;
foreach (var attr in attributes)
{
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
string paramName = $"@Val{i}";
string paramKey = $"@Key{i}";
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}
)";
parameters.Add($"Val{i}", $"%{attr.Value}%");
parameters.Add($"Key{i}", attr.Key);
i++;
}
}
sql += " ORDER BY l.CreatedAt DESC";
@@ -140,8 +216,6 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime targetDate)
{
using var conn = _connectionFactory.CreateConnection();
// La lógica: El aviso debe haber empezado antes o en la fecha target
// Y la fecha target debe ser menor a la fecha de inicio + duración
var sql = @"
SELECT l.*, c.Name as CategoryName
FROM Listings l
@@ -149,8 +223,190 @@ public class ListingRepository : IListingRepository
WHERE l.PrintStartDate IS NOT NULL
AND @TargetDate >= CAST(l.PrintStartDate AS DATE)
AND @TargetDate < DATEADD(day, l.PrintDaysCount, CAST(l.PrintStartDate AS DATE))
ORDER BY c.Name, l.Title"; // Ordenado por Rubro y luego alfabético
ORDER BY c.Name, l.Title";
return await conn.QueryAsync<Listing>(sql, new { TargetDate = targetDate.Date });
}
public async Task<IEnumerable<Listing>> GetPendingModerationAsync()
{
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");
}
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 });
}
public async Task<int> CountByCategoryIdAsync(int categoryId)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE CategoryId = @Id", new { Id = categoryId });
}
public async Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId)
{
using var conn = _connectionFactory.CreateConnection();
// Solo movemos los avisos, no tocamos la estructura de categorías
await conn.ExecuteAsync(
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceCategoryId, TargetId = targetCategoryId });
}
public async Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
// SQL con CTE Recursiva:
// 1. Mapeamos TODA la jerarquía para saber cuál es el ID raíz de cada subcategoría.
// 2. Unimos con Listings y sumamos.
var sql = @"
WITH CategoryHierarchy AS (
-- Caso base: Categorías Raíz (donde ParentId es NULL)
SELECT Id, Id as RootId, Name
FROM Categories
WHERE ParentId IS NULL
UNION ALL
-- Caso recursivo: Hijos que se vinculan a su padre
SELECT c.Id, ch.RootId, ch.Name
FROM Categories c
INNER JOIN CategoryHierarchy ch ON c.ParentId = ch.Id
)
SELECT
ch.RootId as CategoryId,
ch.Name as CategoryName,
SUM(l.Price) as TotalSales,
COUNT(l.Id) as AdCount
FROM Listings l
INNER JOIN CategoryHierarchy ch ON l.CategoryId = ch.Id
WHERE l.CreatedAt >= @StartDate AND l.CreatedAt <= @EndDate
AND l.Status = 'Published'
GROUP BY ch.RootId, ch.Name
ORDER BY TotalSales DESC";
return await conn.QueryAsync<CategorySalesReportDto>(sql, new { StartDate = startDate, EndDate = endDate });
}
public async Task<int> GetPendingCountAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Contamos solo avisos en estado 'Pending'
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE Status = 'Pending'");
}
public async Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
var stats = new DashboardStats();
// 1. KPIs del periodo seleccionado
var kpisSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as RevenueToday,
COUNT(Id) as AdsToday
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
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.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
// 2. Ocupación (basada en el último día del rango)
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
// 3. Tendencia del periodo
// Si el rango es mayor a 10 días, agrupamos diferente, pero por ahora mantenemos ddd
var trendSql = @"
SELECT
FORMAT(CreatedAt, 'dd/MM') as Day,
SUM(AdFee) as Amount
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
AND Status = 'Published'
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
ORDER BY CAST(CreatedAt AS DATE) ASC";
var trendResult = await conn.QueryAsync<DailyRevenue>(trendSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.WeeklyTrend = trendResult.ToList();
// 4. Mix de Canales del periodo
var channelsSql = @"
SELECT
CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END as Name,
COUNT(Id) as Value
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
AND Status = 'Published'
GROUP BY CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END";
var channelResult = await conn.QueryAsync<ChannelStat>(channelsSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.ChannelMix = channelResult.ToList();
return stats;
}
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
// 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'";
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)
{
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
});
report.Items = items.ToList();
report.TotalRevenue = report.Items.Sum(x => x.Amount);
report.TotalAds = report.Items.Count;
return report;
}
}

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
</ItemGroup>
<PropertyGroup>

View File

@@ -1,4 +1,4 @@
using SIGCM.Application.DTOs; // Asegúrate de crear este DTO (ver abajo)
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
using System.Text.RegularExpressions;
@@ -27,14 +27,19 @@ public class PricingService
};
// 2. Análisis del Texto
var words = request.Text.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
int realWordCount = words.Length;
// Contar caracteres especiales configurados en BD (ej: "!")
// Escapamos los caracteres por seguridad en Regex
// A. Contar caracteres especiales PRIMERO (antes de borrarlos)
string escapedSpecialChars = Regex.Escape(pricing.SpecialChars ?? "!");
int specialCharCount = Regex.Matches(request.Text, $"[{escapedSpecialChars}]").Count;
// B. Normalizar el texto para contar palabras
// Reemplazamos los caracteres especiales por espacios para que no cuenten como palabras,
// ni unan palabras (ej: "Hola!Chau" -> "Hola Chau")
string cleanText = Regex.Replace(request.Text, $"[{escapedSpecialChars}]", " ");
// C. Contar palabras reales (ignorando los signos que ahora son espacios)
var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
int realWordCount = words.Length;
// 3. Costo Base y Excedente
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras
@@ -99,13 +104,13 @@ public class PricingService
return new CalculatePriceResponse
{
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice,
ExtraCost = extraWordCost + specialCharCost,
Surcharges = (request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0),
BaseCost = pricing.BasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
Discount = totalDiscount,
WordCount = realWordCount,
SpecialCharCount = specialCharCount,
Details = $"Base: ${pricing.BasePrice} | Extras: ${extraWordCost + specialCharCost} | Desc: -${totalDiscount} ({string.Join(", ", appliedPromos)})"
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
};
}
}

View File

@@ -0,0 +1,109 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SIGCM.Domain.Models;
namespace SIGCM.Infrastructure.Services;
public static class ReportGenerator
{
public static byte[] GenerateSalesPdf(GlobalReportDto data, string title)
{
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Helvetica"));
// --- ENCABEZADO ---
page.Header().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("DIARIO EL DIA").FontSize(20).SemiBold().FontColor(Colors.Blue.Medium);
col.Item().Text("Sistema de Gestión de Avisos").FontSize(9).Italic();
});
row.RelativeItem().AlignRight().Column(col =>
{
col.Item().Text(title).FontSize(14).SemiBold();
col.Item().Text($"Periodo: {data.FromDate:dd/MM/yyyy} - {data.ToDate:dd/MM/yyyy}").FontSize(9);
col.Item().Text($"Generado: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8).FontColor(Colors.Grey.Medium);
});
});
// --- CONTENIDO ---
page.Content().PaddingVertical(20).Column(col =>
{
// Resumen de KPIs en el PDF
col.Item().Row(row =>
{
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
{
c.Item().Text("TOTAL RECAUDADO").FontSize(8).SemiBold();
c.Item().Text($"${data.TotalRevenue:N2}").FontSize(16).Black();
});
row.ConstantItem(10);
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
{
c.Item().Text("CANTIDAD DE AVISOS").FontSize(8).SemiBold();
c.Item().Text($"{data.TotalAds}").FontSize(16).Black();
});
});
col.Item().PaddingTop(20);
// Tabla de detalles
col.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(40); // ID
columns.ConstantColumn(80); // Fecha
columns.RelativeColumn(); // Titulo
columns.RelativeColumn(); // Rubro
columns.RelativeColumn(); // Cajero
columns.ConstantColumn(70); // Monto
});
// Encabezado de tabla
table.Header(header =>
{
header.Cell().Element(CellStyle).Text("ID");
header.Cell().Element(CellStyle).Text("FECHA");
header.Cell().Element(CellStyle).Text("TITULO");
header.Cell().Element(CellStyle).Text("RUBRO");
header.Cell().Element(CellStyle).Text("CAJERO");
header.Cell().Element(CellStyle).AlignRight().Text("MONTO");
static IContainer CellStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold()).PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Black);
});
// Filas
foreach (var item in data.Items)
{
table.Cell().Element(RowStyle).Text(item.Id.ToString());
table.Cell().Element(RowStyle).Text(item.Date.ToString("dd/MM HH:mm"));
table.Cell().Element(RowStyle).Text(item.Title);
table.Cell().Element(RowStyle).Text(item.Category);
table.Cell().Element(RowStyle).Text(item.Cashier);
table.Cell().Element(RowStyle).AlignRight().Text($"${item.Amount:N2}");
static IContainer RowStyle(IContainer container) => container.PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten4);
}
});
});
// --- PIE DE PAGINA ---
page.Footer().AlignCenter().Text(x =>
{
x.Span("Página ");
x.CurrentPageNumber();
});
});
}).GeneratePdf();
}
}