Add Export QUOTES & refactoring
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 6m2s

This commit is contained in:
Leandro Hernan Rojas 2025-09-11 22:41:46 -03:00
parent 4c99757fb4
commit 0361d4c978
11 changed files with 213 additions and 58 deletions

View File

@ -11,7 +11,7 @@ namespace Core.Interfaces
Task<QuoteDto?> GetDtoByQuoteNumberAsync(string quoteNumber);
#endregion
#region Exportación
//Task<byte[]> ExportFilteredQuotesToExcelAsync(QuoteSearchParams searchParams);
Task<byte[]> ExportFilteredToExcelAsync(QuoteSearchParams searchParams);
#endregion
#region Guardado completo de presupuesto (encabezado + detalles + roles + ajustes + impuestos)
Task<(int Id, string Quotenumber)> CreateFullQuoteAsync(EQuoteHeader quote, int formSeriesId);

View File

@ -1,9 +1,12 @@
using Domain.Dtos;
using Core.Interfaces;
using Domain.Constants;
using Domain.Dtos;
using Domain.Entities;
using Domain.Generics;
using Models.Interfaces;
using Core.Interfaces;
using System.Drawing.Printing;
using System.Reflection;
using Transversal.Services;
namespace Core.Services
{
@ -38,6 +41,61 @@ namespace Core.Services
{
return await _quoteRepository.GetDtoByIdAsync(id);
}
public async Task<byte[]> ExportFilteredToExcelAsync(QuoteSearchParams searchParams)
{
try
{
// Realiza la búsqueda de clientes con los parámetros proporcionados
var searchResult = await _quoteRepository.SearchAsync(
searchParams.CustomerId,
searchParams.CustomerText,
searchParams.QuoteNumber,
searchParams.ProfessionalId,
searchParams.ProfessionalText,
searchParams.InstitutionId,
searchParams.InstitutionText,
searchParams.PatientId,
searchParams.PatientText,
searchParams.IssueDateFrom,
searchParams.IssueDateTo,
searchParams.Status,
searchParams.Page,
searchParams.PageSize
);
// Verifica que se hayan encontrado resultados
if (searchResult?.Items is null || !searchResult.Items.Any())
{
throw new Exception("No se encontraron clientes para exportar.");
}
// Llamamos a un método que exporta los datos a Excel
var stream = new XLSXExportBase();
// Convertimos los resultados de la búsqueda a un formato adecuado para el exportador
var items = searchResult.Items.Select(c => new
{
c.Quotenumber,
Issuedate = c.IssueDate.ToString("dd/MM/yyyy"), // ← string
EstimatedDate = c.EstimatedDate?.ToString("dd/MM/yyyy HH:mm"), // ← string
c.Status,
c.CustomerName,
c.ProfessionalName,
c.InstitutionName,
c.PatientName,
c.BusinessUnitName,
c.SalespersonName,
c.Observations,
c.Total
}).ToList();
// Genera el archivo Excel
var excelFile = stream.ExportExcel(items);
// Devuelve el archivo Excel como un array de bytes
return excelFile;
}
catch (Exception ex)
{
var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod";
throw new Exception($"{ex.Message}", ex);
}
}
//public async Task<QuoteDto?> GetDtoByQuoteNumberAsync(string quoteNumber)
//{

View File

@ -55,7 +55,7 @@ namespace Core.Services.Stock
try
{
// Realiza la búsqueda de clientes con los parámetros proporcionados
var searchResult = await SearchAsync(
var searchResult = await _repo.SearchAsync(
searchParams.Number,
searchParams.Status,
searchParams.From,

View File

@ -135,6 +135,21 @@ namespace phronCare.API.Controllers.Sales
return File(pdfBytes, "application/pdf", $"Presupuesto_{quote.Quotenumber}.pdf");
}
[HttpPost("exportfiltered")]
public async Task<IActionResult> ExportFiltered([FromBody] QuoteSearchParams searchParams)
{
try
{
var file = await _quoteService.ExportFilteredToExcelAsync(searchParams);
return File(file,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Expediciones.xlsx");
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
#region Endpoint de emision de presupuesto (encabezado + detalles + roles + ajustes + impuestos)
[HttpPost("createfull")]

View File

@ -128,8 +128,8 @@ namespace phronCare.API.Controllers.Stock
return File(pdfBytes, "application/pdf", $"Expedicion_{expedition.Expeditionnumber}.pdf");
}
[HttpPost("exportfiltered")]
[HttpPost("exportfiltered")]
public async Task<IActionResult> ExportFiltered([FromBody] ExpeditionSearchParams searchParams)
{
try

View File

@ -2383,6 +2383,22 @@
],
"ReturnTypes": []
},
{
"ContainingType": "phronCare.API.Controllers.Sales.QuoteController",
"Method": "ExportFiltered",
"RelativePath": "api/Quote/exportfiltered",
"HttpMethod": "POST",
"IsController": true,
"Order": 0,
"Parameters": [
{
"Name": "searchParams",
"Type": "Domain.Generics.QuoteSearchParams",
"IsRequired": true
}
],
"ReturnTypes": []
},
{
"ContainingType": "phronCare.API.Controllers.Sales.QuoteController",
"Method": "Search",

View File

@ -1,10 +1,9 @@
@page "/quotes"
@using Domain.Dtos
@using Domain.Generics
@using phronCare.UIBlazor.Services.Sales.Quotes
@inject NavigationManager Navigation
@inject QuoteService quoteService
@inject IQuoteService quoteService
@inject IToastService toastService
<div class="card shadow-sm mb-3" style="zoom: 0.8;">
@ -14,7 +13,7 @@
</div>
</div>
<!-- BODY -->
<div class="card-body">
<div class="card-body pt-2 pb-0">
<!-- FILTROS -->
<div class="mb-3 row g-2 align-items-end">
<div class="col-sm">
@ -58,21 +57,24 @@
<InputDate id="dateto" @bind-Value="Filters.IssueDateTo" class="form-control form-control-sm" />
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<button class="btn btn-primary btn-sm rounded-pill" @onclick="Search">
<button class="btn btn-primary rounded-pill" @onclick="Search">
<i class="fas fa-binoculars me-1"></i> Buscar
</button>
<button class="btn btn-secondary btn-sm rounded-pill ms-1" @onclick="OnClear">
<button class="btn btn-secondary rounded-pill ms-1" @onclick="OnClear">
<i class="fas fa-eraser me-1"></i> Limpiar
</button>
<button class="btn btn-success btn-sm rounded-pill" @onclick="Create">
<button class="btn btn-success rounded-pill" @onclick="Create">
<i class="fas fa-plus me-1"></i> Nuevo
</button>
<button class="btn btn-success rounded-pill" @onclick="ExportarExcel">
<i class="fas fa-file-excel me-1"></i> Excel
</button>
</div>
</div>
</div>
</div>
<div class="card shadow-sm" style="zoom:0.8;">
<div class="table-responsive">
<div class="table-responsive" style="zoom:0.8;">
<!-- TABLA DE RESULTADOS -->
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
@ -139,8 +141,8 @@
</table>
</div>
<!-- Paginación debajo -->
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<div class="btn-group">
<div class="d-flex justify-content-center align-items-center px-3 py-2 border-top">
<div class="btn-group justify-content-center">
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="PrimeraPagina" disabled="@(Filters.Page == 1)">Primera</button>
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="AnteriorPagina" disabled="@(!PuedeRetroceder)">Anterior</button>
<span class="mx-2">
@ -285,9 +287,8 @@
</div>
}
@code {
private QuoteSearchParams Filters = new() { PageSize = 9 };
private QuoteSearchParams Filters = new() { PageSize = 10 };
private PagedResult<QuoteDto>? PagedQuotes;
private QuoteDto? SelectedQuote { get; set; }
private bool IsLoading;
@ -399,5 +400,18 @@
{
Navigation.NavigateTo($"/quotes/authorize/{id}");
}
private async Task ExportarExcel()
{
Filters.Page = 1;
Filters.PageSize = int.MaxValue; // Exportar todos los resultados
try
{
await quoteService.ExportFilteredAsync(Filters);
toastService.ShowSuccess("Exportación completada.");
}
catch (Exception ex)
{
toastService.ShowError($"Error: {ex.Message}");
}
}
}

View File

@ -22,13 +22,13 @@
<div class="row g-2 align-items-end">
<!-- En monitores grandes queda todo en una fila (col-xxl-2 = 6 columnas por fila) -->
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Número</label>
<InputText class="form-control form-control-sm" @bind-Value="filters.Number" />
<label for="number" class="form-label mb-1">Número</label>
<InputText id="number" class="form-control form-control-sm" @bind-Value="filters.Number" />
</div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Estado</label>
<InputSelect class="form-select form-select-sm" @bind-Value="filters.Status">
<label for="status" class="form-label mb-1">Estado</label>
<InputSelect id="status" class="form-select form-select-sm" @bind-Value="filters.Status">
<option value="">(Todos)</option>
<option value="Emitida">Emitida</option>
<option value="EnTransito">En tránsito</option>
@ -40,24 +40,24 @@
</div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Fecha desde</label>
<InputDate class="form-control form-control-sm" @bind-Value="filters.From" />
<label for="from" class="form-label mb-1">Fecha desde</label>
<InputDate id="from" class="form-control form-control-sm" @bind-Value="filters.From" />
</div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Fecha hasta</label>
<InputDate class="form-control form-control-sm" @bind-Value="filters.To" />
<label for="to" class="form-label mb-1">Fecha hasta</label>
<InputDate id="to" class="form-control form-control-sm" @bind-Value="filters.To" />
</div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Ubicación</label>
<InputNumber class="form-control form-control-sm" @bind-Value="filters.LocationId" />
<label for="location" class="form-label mb-1">Ubicación</label>
<InputNumber id="location" class="form-control form-control-sm" @bind-Value="filters.LocationId" />
@* TODO: reemplazar por BlazoredTypeahead cuando conectes lookup de ubicaciones *@
</div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Tam. página</label>
<InputSelect class="form-select form-select-sm" @bind-Value="pageSize" @onchange="@(e => ChangePageSize(e.Value?.ToString()))">
<label for="pageg" class="form-label mb-1">Tam. página</label>
<InputSelect id="pageg"class="form-select form-select-sm" @bind-Value="pageSize" @onchange="@(e => ChangePageSize(e.Value?.ToString()))">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
@ -72,12 +72,12 @@
<button class="btn btn-success rounded-pill" @onclick="Create">
<i class="fas fa-plus me-1"></i> Nuevo
</button>
<button class="btn btn-secondary rounded-pill" @onclick="Clear">
<i class="fas fa-eraser me-1"></i> Limpiar
</button>
<button class="btn btn-success rounded-pill" @onclick="ExportarExcel">
<i class="fas fa-file-excel me-1"></i> Excel
</button>
<button class="btn btn-secondary rounded-pill" @onclick="Clear">
<i class="fas fa-arrow-left me-1"></i> Volver
</button>
</div>
</EditForm>
</div>
@ -127,6 +127,10 @@
</tr>
}
}
else if (IsLoading)
{
<tr><td colspan="9" class="text-center text-muted py-4">Cargando...</td></tr>
}
else
{
<tr><td colspan="9" class="text-center text-muted py-4">Sin resultados</td></tr>
@ -170,6 +174,8 @@
private Filters filters = new();
private PagedResult<ExpeditionDto>? result;
private bool IsLoading;
private int page = 1;
private int pageSize = 10;
private int TotalPages => result is null ? 1 : (int)Math.Ceiling((double)result.TotalItems / result.PageSize);
@ -203,6 +209,9 @@
private async Task Search()
{
try
{
IsLoading = true;
result = await expeditionService.SearchAsync(
expeditionNumber: filters.Number,
status: filters.Status,
@ -213,6 +222,15 @@
pageSize: pageSize);
StateHasChanged();
}
catch (Exception ex)
{
Toast.ShowError(ex.Message);
}
finally
{
IsLoading = false;
}
}
private void Clear()
{

View File

@ -54,9 +54,9 @@ static void InjectDependencies(WebAssemblyHostBuilder builder)
builder.Services.AddScoped<IExchangeRateService, ExchangeRateService>();
builder.Services.AddScoped<IStockScanService, StockScanService>();
builder.Services.AddScoped<IExpeditionService, ExpeditionService>();
builder.Services.AddScoped<IQuoteService,QuoteService>();
builder.Services.AddScoped<ExchangeRateService>();
builder.Services.AddScoped<QuoteService>();
builder.Services.AddScoped<TicketsService>();
builder.Services.AddScoped<CustomerService>();
builder.Services.AddScoped<TaxConditionService>();

View File

@ -1,11 +1,16 @@
using Domain.Entities;
using phronCare.UIBlazor.Services.Sales.Quotes;
using Domain.Dtos;
using Domain.Entities;
using Domain.Generics;
namespace Services.Sales.Quotes
namespace phronCare.UIBlazor.Services.Sales.Quotes
{
public interface IQuoteService
{
//Task<CreateQuoteResult> CreateFullQuoteAsync(EQuoteHeader quote, int formSeriesId);
// Aquí podrías agregar otros métodos: GetById, Search, etc.
Task<bool> AuthorizeQuoteAsync(QuoteAuthorizationRequest request);
Task<CreateQuoteResult> CreateFullQuoteAsync(EQuoteHeader quote, int formSeriesId);
Task ExportFilteredAsync(QuoteSearchParams searchParams);
Task ExportPdfAsync(int quoteId, string quoteNumber);
Task<QuoteDto?> GetDtoByIdAsync(int id);
Task<PagedResult<QuoteDto>> SearchAsync(int? customerId = null, string? customerText = null, string? quoteNumber = null, int? professionalId = null, string? professionalText = null, int? institutionId = null, string? institutionText = null, int? patientId = null, string? patientText = null, string? status = null, DateTime? issueDateFrom = null, DateTime? issueDateTo = null, int page = 1, int pageSize = 10);
}
}

View File

@ -3,10 +3,13 @@ using Domain.Entities;
using Domain.Generics;
using Microsoft.JSInterop;
using System.Net.Http.Json;
using System.Reflection;
using System.Text;
using System.Text.Json;
namespace phronCare.UIBlazor.Services.Sales.Quotes
{
public class QuoteService
public class QuoteService:IQuoteService
{
private readonly IJSRuntime _js;
private readonly HttpClient _http;
@ -156,6 +159,32 @@ namespace phronCare.UIBlazor.Services.Sales.Quotes
return null;
}
}
public async Task ExportFilteredAsync(QuoteSearchParams searchParams)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(searchParams), Encoding.UTF8, "application/json");
var response = await _http.PostAsync("api/quote/exportfiltered", content);
//response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new Exception(errorContent);
}
var bytes = await response.Content.ReadAsByteArrayAsync();
var base64 = Convert.ToBase64String(bytes);
var timestamp = DateTime.Now.ToString("yyyyMMddHHmm");
var fileName = $"{timestamp}_quotes.xlsx";
await _js.InvokeVoidAsync("saveAsFile", fileName, base64);
}
catch (Exception ex)
{
var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod";
var message = ex.Message ?? "No message provided";
throw new Exception($"{message}", ex);
}
}
}