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); Task<QuoteDto?> GetDtoByQuoteNumberAsync(string quoteNumber);
#endregion #endregion
#region Exportación #region Exportación
//Task<byte[]> ExportFilteredQuotesToExcelAsync(QuoteSearchParams searchParams); Task<byte[]> ExportFilteredToExcelAsync(QuoteSearchParams searchParams);
#endregion #endregion
#region Guardado completo de presupuesto (encabezado + detalles + roles + ajustes + impuestos) #region Guardado completo de presupuesto (encabezado + detalles + roles + ajustes + impuestos)
Task<(int Id, string Quotenumber)> CreateFullQuoteAsync(EQuoteHeader quote, int formSeriesId); 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.Constants;
using Domain.Dtos;
using Domain.Entities; using Domain.Entities;
using Domain.Generics; using Domain.Generics;
using Models.Interfaces; using Models.Interfaces;
using Core.Interfaces; using System.Drawing.Printing;
using System.Reflection;
using Transversal.Services;
namespace Core.Services namespace Core.Services
{ {
@ -38,6 +41,61 @@ namespace Core.Services
{ {
return await _quoteRepository.GetDtoByIdAsync(id); 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) //public async Task<QuoteDto?> GetDtoByQuoteNumberAsync(string quoteNumber)
//{ //{

View File

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

View File

@ -135,6 +135,21 @@ namespace phronCare.API.Controllers.Sales
return File(pdfBytes, "application/pdf", $"Presupuesto_{quote.Quotenumber}.pdf"); 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) #region Endpoint de emision de presupuesto (encabezado + detalles + roles + ajustes + impuestos)
[HttpPost("createfull")] [HttpPost("createfull")]

View File

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

View File

@ -2383,6 +2383,22 @@
], ],
"ReturnTypes": [] "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", "ContainingType": "phronCare.API.Controllers.Sales.QuoteController",
"Method": "Search", "Method": "Search",

View File

@ -1,10 +1,9 @@
@page "/quotes" @page "/quotes"
@using Domain.Dtos @using Domain.Dtos
@using Domain.Generics @using Domain.Generics
@using phronCare.UIBlazor.Services.Sales.Quotes @using phronCare.UIBlazor.Services.Sales.Quotes
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject QuoteService quoteService @inject IQuoteService quoteService
@inject IToastService toastService @inject IToastService toastService
<div class="card shadow-sm mb-3" style="zoom: 0.8;"> <div class="card shadow-sm mb-3" style="zoom: 0.8;">
@ -14,7 +13,7 @@
</div> </div>
</div> </div>
<!-- BODY --> <!-- BODY -->
<div class="card-body"> <div class="card-body pt-2 pb-0">
<!-- FILTROS --> <!-- FILTROS -->
<div class="mb-3 row g-2 align-items-end"> <div class="mb-3 row g-2 align-items-end">
<div class="col-sm"> <div class="col-sm">
@ -58,21 +57,24 @@
<InputDate id="dateto" @bind-Value="Filters.IssueDateTo" class="form-control form-control-sm" /> <InputDate id="dateto" @bind-Value="Filters.IssueDateTo" class="form-control form-control-sm" />
</div> </div>
<div class="d-flex justify-content-end gap-2 mt-3"> <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 <i class="fas fa-binoculars me-1"></i> Buscar
</button> </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 <i class="fas fa-eraser me-1"></i> Limpiar
</button> </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 <i class="fas fa-plus me-1"></i> Nuevo
</button> </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> </div>
</div> </div>
<div class="card shadow-sm" style="zoom:0.8;"> <div class="card shadow-sm" style="zoom:0.8;">
<div class="table-responsive"> <div class="table-responsive" style="zoom:0.8;">
<!-- TABLA DE RESULTADOS --> <!-- TABLA DE RESULTADOS -->
<table class="table table-sm align-middle mb-0"> <table class="table table-sm align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@ -139,8 +141,8 @@
</table> </table>
</div> </div>
<!-- Paginación debajo --> <!-- Paginación debajo -->
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top"> <div class="d-flex justify-content-center align-items-center px-3 py-2 border-top">
<div class="btn-group"> <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="PrimeraPagina" disabled="@(Filters.Page == 1)">Primera</button>
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="AnteriorPagina" disabled="@(!PuedeRetroceder)">Anterior</button> <button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="AnteriorPagina" disabled="@(!PuedeRetroceder)">Anterior</button>
<span class="mx-2"> <span class="mx-2">
@ -285,9 +287,8 @@
</div> </div>
} }
@code { @code {
private QuoteSearchParams Filters = new() { PageSize = 9 }; private QuoteSearchParams Filters = new() { PageSize = 10 };
private PagedResult<QuoteDto>? PagedQuotes; private PagedResult<QuoteDto>? PagedQuotes;
private QuoteDto? SelectedQuote { get; set; } private QuoteDto? SelectedQuote { get; set; }
private bool IsLoading; private bool IsLoading;
@ -399,5 +400,18 @@
{ {
Navigation.NavigateTo($"/quotes/authorize/{id}"); 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"> <div class="row g-2 align-items-end">
<!-- En monitores grandes queda todo en una fila (col-xxl-2 = 6 columnas por fila) --> <!-- 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"> <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> <label for="number" class="form-label mb-1">Número</label>
<InputText class="form-control form-control-sm" @bind-Value="filters.Number" /> <InputText id="number" class="form-control form-control-sm" @bind-Value="filters.Number" />
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2"> <div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2">
<label class="form-label mb-1">Estado</label> <label for="status" class="form-label mb-1">Estado</label>
<InputSelect class="form-select form-select-sm" @bind-Value="filters.Status"> <InputSelect id="status" class="form-select form-select-sm" @bind-Value="filters.Status">
<option value="">(Todos)</option> <option value="">(Todos)</option>
<option value="Emitida">Emitida</option> <option value="Emitida">Emitida</option>
<option value="EnTransito">En tránsito</option> <option value="EnTransito">En tránsito</option>
@ -40,24 +40,24 @@
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2"> <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> <label for="from" class="form-label mb-1">Fecha desde</label>
<InputDate class="form-control form-control-sm" @bind-Value="filters.From" /> <InputDate id="from" class="form-control form-control-sm" @bind-Value="filters.From" />
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2"> <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> <label for="to" class="form-label mb-1">Fecha hasta</label>
<InputDate class="form-control form-control-sm" @bind-Value="filters.To" /> <InputDate id="to" class="form-control form-control-sm" @bind-Value="filters.To" />
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2"> <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> <label for="location" class="form-label mb-1">Ubicación</label>
<InputNumber class="form-control form-control-sm" @bind-Value="filters.LocationId" /> <InputNumber id="location" class="form-control form-control-sm" @bind-Value="filters.LocationId" />
@* TODO: reemplazar por BlazoredTypeahead cuando conectes lookup de ubicaciones *@ @* TODO: reemplazar por BlazoredTypeahead cuando conectes lookup de ubicaciones *@
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xxl-2"> <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> <label for="pageg" 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()))"> <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="10">10</option>
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
@ -72,13 +72,13 @@
<button class="btn btn-success rounded-pill" @onclick="Create"> <button class="btn btn-success rounded-pill" @onclick="Create">
<i class="fas fa-plus me-1"></i> Nuevo <i class="fas fa-plus me-1"></i> Nuevo
</button> </button>
<button class="btn btn-success rounded-pill" @onclick="ExportarExcel"> <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 <i class="fas fa-file-excel me-1"></i> Excel
</button> </button>
<button class="btn btn-secondary rounded-pill" @onclick="Clear"> </div>
<i class="fas fa-arrow-left me-1"></i> Volver
</button>
</div>
</EditForm> </EditForm>
</div> </div>
</div> </div>
@ -127,9 +127,13 @@
</tr> </tr>
} }
} }
else if (IsLoading)
{
<tr><td colspan="9" class="text-center text-muted py-4">Cargando...</td></tr>
}
else else
{ {
<tr><td colspan="9" class="text-center text-muted py-4">Sin resultados</td></tr> <tr><td colspan="9" class="text-center text-muted py-4">Sin resultados</td></tr>
} }
</tbody> </tbody>
</table> </table>
@ -168,11 +172,13 @@
@code { @code {
private LSProductSearchParams SearchParams = new() { Page = 1, PageSize = 10 }; private LSProductSearchParams SearchParams = new() { Page = 1, PageSize = 10 };
private Filters filters = new(); private Filters filters = new();
private PagedResult<ExpeditionDto>? result; private PagedResult<ExpeditionDto>? result;
private int page = 1; private bool IsLoading;
private int pageSize = 10;
private int TotalPages => result is null ? 1 : (int)Math.Ceiling((double)result.TotalItems / result.PageSize); private int page = 1;
private int pageSize = 10;
private int TotalPages => result is null ? 1 : (int)Math.Ceiling((double)result.TotalItems / result.PageSize);
private ExpeditionDto? selected; private ExpeditionDto? selected;
private bool drawerOpen; private bool drawerOpen;
@ -203,16 +209,28 @@
private async Task Search() private async Task Search()
{ {
result = await expeditionService.SearchAsync( try
expeditionNumber: filters.Number, {
status: filters.Status, IsLoading = true;
issueDateFrom: filters.From, result = await expeditionService.SearchAsync(
issueDateTo: filters.To, expeditionNumber: filters.Number,
locationId: filters.LocationId, status: filters.Status,
page: page, issueDateFrom: filters.From,
pageSize: pageSize); issueDateTo: filters.To,
StateHasChanged(); locationId: filters.LocationId,
} page: page,
pageSize: pageSize);
StateHasChanged();
}
catch (Exception ex)
{
Toast.ShowError(ex.Message);
}
finally
{
IsLoading = false;
}
}
private void Clear() private void Clear()
{ {

View File

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

View File

@ -1,11 +1,16 @@
using Domain.Entities; using Domain.Dtos;
using phronCare.UIBlazor.Services.Sales.Quotes; using Domain.Entities;
using Domain.Generics;
namespace Services.Sales.Quotes namespace phronCare.UIBlazor.Services.Sales.Quotes
{ {
public interface IQuoteService public interface IQuoteService
{ {
//Task<CreateQuoteResult> CreateFullQuoteAsync(EQuoteHeader quote, int formSeriesId); Task<bool> AuthorizeQuoteAsync(QuoteAuthorizationRequest request);
// Aquí podrías agregar otros métodos: GetById, Search, etc. 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 Domain.Generics;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Reflection;
using System.Text;
using System.Text.Json;
namespace phronCare.UIBlazor.Services.Sales.Quotes namespace phronCare.UIBlazor.Services.Sales.Quotes
{ {
public class QuoteService public class QuoteService:IQuoteService
{ {
private readonly IJSRuntime _js; private readonly IJSRuntime _js;
private readonly HttpClient _http; private readonly HttpClient _http;
@ -156,7 +159,33 @@ namespace phronCare.UIBlazor.Services.Sales.Quotes
return null; 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);
}
}
} }
public class CreateQuoteResult public class CreateQuoteResult