Add Patients List Completo UI
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 5m22s

This commit is contained in:
Leandro Hernan Rojas 2025-04-21 00:38:46 -03:00
parent 8623488221
commit 4ee5a99cb1
9 changed files with 486 additions and 8 deletions

View File

@ -8,10 +8,13 @@ namespace Domain.Entities
[Required(ErrorMessage = "Debe ingresar un nombre.")] [Required(ErrorMessage = "Debe ingresar un nombre.")]
public string Firstname { get; set; } = null!; public string Firstname { get; set; } = null!;
public string Lastname { get; set; } = null!; public string Lastname { get; set; } = null!;
[Required(ErrorMessage = "Debe ingresar un tipo de documento.")]
public int? DocumenttypesId { get; set; } public int? DocumenttypesId { get; set; }
[Required(ErrorMessage = "Debe ingresar un numero de documento.")]
public string? DocumentNumber { get; set; } public string? DocumentNumber { get; set; }
public string? AffiliateNumber { get; set; } public string? AffiliateNumber { get; set; }
public DateOnly? Birthdate { get; set; } public DateOnly? Birthdate { get; set; }
[Required(ErrorMessage = "Debe seleccionar un genero.")]
public string? Gender { get; set; } public string? Gender { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
public string? Email { get; set; } public string? Email { get; set; }

View File

@ -90,7 +90,7 @@ namespace phronCare.API.Controllers.Sales
catch (Exception ex) catch (Exception ex)
{ {
var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod";
return StatusCode(500, $"{methodName} Message: {ex.Message}"); return BadRequest($"Error de negocio: {ex.Message}");
} }
} }
@ -112,7 +112,7 @@ namespace phronCare.API.Controllers.Sales
catch (Exception ex) catch (Exception ex)
{ {
var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod";
return StatusCode(500, $"{methodName} Message: {ex.Message}"); return StatusCode(500, $"{ex.Message}");
} }
} }

View File

@ -10,6 +10,7 @@
@inject AuthenticationStateProvider authenticationStateProvider @inject AuthenticationStateProvider authenticationStateProvider
@inject AccountTypeService accountTypeService @inject AccountTypeService accountTypeService
@inject TaxConditionService taxConditionService @inject TaxConditionService taxConditionService
@inject DocumentTypeService documentTypeService
<div class="card" style="zoom:80%"> <div class="card" style="zoom:80%">
<div class="card-header d-flex justify-content-center align-items-center"> <div class="card-header d-flex justify-content-center align-items-center">
@ -364,8 +365,7 @@
} }
private async Task LoadDocumentTypes() private async Task LoadDocumentTypes()
{ {
var result = await _httpClient.GetFromJsonAsync<List<EDocumentType>>("/api/DocumentType/GetAll"); documentTypes = await documentTypeService.GetAllAsync();
documentTypes = result ?? new();
} }
private void AddCustomerDocument() private void AddCustomerDocument()
{ {

View File

@ -0,0 +1,184 @@
@page "/sales/patientform"
@page "/sales/patientform/{PatientId:int?}"
@using System.ComponentModel.DataAnnotations
@using phronCare.UIBlazor.Services.Sales
@using phronCare.UIBlazor.Pages.Shared.Modals
@inject PatientService patientService
@inject DocumentTypeService documentTypeService
@inject IToastService ToastService
@inject NavigationManager Navigation
@inject IModalService Modal
<div class="card" style="zoom:80%">
<div class="card-header d-flex justify-content-center align-items-center">
<h3 class="card-title m-0">@((PatientId.HasValue ? "Editar paciente" : "Nuevo paciente"))</h3>
</div>
<div class="card-body">
<EditForm Model="_model" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<!-- Fila 1: Nombre y Apellido -->
<div class="row mb-3">
<div class="col-md-6">
<label for="FirstName">Nombre:</label>
<InputText id="FirstName" @bind-Value="_model.Firstname" class="form-control" />
<ValidationMessage For="@(() => _model.Firstname)" />
</div>
<div class="col-md-6">
<label for="LastName">Apellido:</label>
<InputText id="LastName" @bind-Value="_model.Lastname" class="form-control" />
<ValidationMessage For="@(() => _model.Lastname)" />
</div>
</div>
<!-- Fila 2: Tipo Doc, Nro Doc, Afiliado -->
<div class="row mb-3">
<div class="col-md-4">
<label for="DocumentType">Tipo de documento:</label>
<InputSelect id="DocumentType" @bind-Value="_model.DocumenttypesId" class="form-control">
<option value="">Seleccione un tipo</option>
@foreach (var type in documentTypes)
{
<option value="@type.Id">@type.Description</option>
}
</InputSelect>
<ValidationMessage For="@(() => _model.DocumenttypesId)" />
</div>
<div class="col-md-4">
<label for="DocumentNumber">Número de documento:</label>
<InputText id="DocumentNumber" @bind-Value="_model.DocumentNumber" class="form-control" />
<ValidationMessage For="@(() => _model.DocumentNumber)" />
</div>
<div class="col-md-4">
<label for="AffiliateNumber">Número de afiliado:</label>
<InputText id="AffiliateNumber" @bind-Value="_model.AffiliateNumber" class="form-control" />
<ValidationMessage For="@(() => _model.AffiliateNumber)" />
</div>
</div>
<!-- Fila 3: Fecha de nacimiento, Género -->
<div class="row mb-3">
<div class="col-md-4">
<label for="Birthdate">Fecha de nacimiento:</label>
<InputDate id="Birthdate" @bind-Value="_model.Birthdate" class="form-control" />
<ValidationMessage For="@(() => _model.Birthdate)" />
</div>
<div class="col-md-4">
<label for="Gender">Género:</label>
<InputSelect id="Gender" @bind-Value="_model.Gender" class="form-control">
<option value="">Seleccione</option>
<option value="Femenino">Femenino</option>
<option value="Masculino">Masculino</option>
<option value="Otro">Otro</option>
<option value="Prefiero no decir">Prefiero no decir</option>
</InputSelect>
<ValidationMessage For="@(() => _model.Gender)" />
</div>
</div>
<!-- Fila 4: Teléfono, Email -->
<div class="row mb-3">
<div class="col-md-6">
<label for="Phone">Teléfono:</label>
<InputText id="Phone" @bind-Value="_model.Phone" class="form-control" />
<ValidationMessage For="@(() => _model.Phone)" />
</div>
<div class="col-md-6">
<label for="Email">Email:</label>
<InputText id="Email" @bind-Value="_model.Email" class="form-control" />
<ValidationMessage For="@(() => _model.Email)" />
</div>
</div>
<!-- Fila 5: Dirección -->
<div class="row mb-3">
<div class="col-12">
<label for="Address">Dirección:</label>
<InputText id="Address" @bind-Value="_model.Address" class="form-control" />
<ValidationMessage For="@(() => _model.Address)" />
</div>
</div>
<!-- Fila 6: Notas -->
<div class="row mb-3">
<div class="col-12">
<label for="Notes">Notas:</label>
<InputTextArea id="Notes" @bind-Value="_model.Notes" class="form-control" rows="3" />
<ValidationMessage For="@(() => _model.Notes)" />
</div>
</div>
</EditForm>
</div>
<div class="card-footer">
<div class="d-flex justify-content-end align-items-center py-3">
<button class="btn btn-primary me-2" type="button" @onclick="HandleValidSubmit" disabled="@isSaving"> @(isSaving ? "Guardando..." : "Guardar paciente") </button>
<button type="button" class="btn btn-secondary" @onclick="NavigateBack">Cancelar</button>
</div>
</div>
</div>
@code {
[Parameter] public int? PatientId { get; set; }
[Parameter] public string? returnUrl { get; set; } = "/sales/patients";
private EPatient _model = new();
private List<EDocumentType> documentTypes = new();
private bool isSaving = false;
protected override async Task OnInitializedAsync()
{
await LoadDocumentTypes();
if (PatientId.HasValue)
{
_model = await patientService.GetByIdAsync(PatientId.Value);
}
}
private async Task LoadDocumentTypes()
{
documentTypes = await documentTypeService.GetAllAsync();
}
private async Task HandleValidSubmit()
{
var parameters = new ModalParameters();
parameters.Add("Message", "¿Desea guardar los cambios del paciente?");
var modal = Modal.Show<ConfirmModal>("Confirmación", parameters);
var result = await modal.Result;
if (result.Cancelled)
return;
try
{
HttpResponseMessage response;
if (_model.Id == 0)
response = await patientService.CreateAsync(_model);
else
response = await patientService.UpdateAsync(_model);
if (response.IsSuccessStatusCode)
{
ToastService.ShowSuccess("Paciente guardado correctamente.");
NavigateBack();
}
else
{
var error = await response.Content.ReadAsStringAsync();
ToastService.ShowError($"Error: {error}");
}
}
catch (Exception ex)
{
ToastService.ShowError($"Error: {ex.Message}");
}
}
private void NavigateBack()
{
Navigation.NavigateTo(returnUrl ?? "/sales/patients");
}
}

View File

@ -0,0 +1,186 @@
@page "/sales/patients"
@using phronCare.UIBlazor.Services.Sales
@using phronCare.UIBlazor.Data
@using Domain.Entities
@using Domain.Generics
@using Domain.SearchParams
@inject IToastService toastService
@inject NavigationManager Navigation
@inject PatientService patientService
<div class="card">
<div class="card-header d-flex justify-content-center align-items-center" style="zoom:80%;">
<h3 class="card-title m-0">Búsqueda de pacientes</h3>
</div>
<div class="card-body" style="zoom:80%;">
<div class="mb-4 space-y-2">
<input @bind="SearchParams.Name" placeholder="Nombre o Apellido" class="border rounded p-1 w-full" />
<input @bind="SearchParams.Document" placeholder="Documento" class="border rounded p-1 w-full" />
<button class="btn btn-primary rounded-pill" @onclick="BuscarPacientes">
<i class="fas fa-binoculars me-1"></i> Buscar
</button>
<button class="btn btn-success rounded-pill" @onclick="NuevoPaciente">
<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>
<button class="btn btn-secondary rounded-pill" @onclick="Volver">
<i class="fas fa-arrow-left me-1"></i> Volver
</button>
</div>
<hr />
<div>
@if (TablaPacientes != null && TablaPacientes.Any())
{
<PhTable Columns="TableColumns"
Data="TablaPacientes"
SelectionField="Id"
RowsPerPage=SearchParams.PageSize
RenderButtons="true" Buttons="botones"
ShowPageButtons="false"
ShowQuickSearch="false"
RenderSelect="false" />
}
else
{
<p>No hay resultados.</p>
}
</div>
</div>
<div class="card-footer d-flex justify-content-center align-items-center" style="zoom:80%;">
<div class="d-flex align-items-center gap-3">
<button class="btn btn-secondary rounded-pill" @onclick="PrimeraPagina" disabled="@(SearchParams.Page == 1)">
<i class="fas fa-angle-double-left me-1"></i> Primera
</button>
<button class="btn btn-secondary rounded-pill" @onclick="AnteriorPagina" disabled="@(!PuedeRetroceder)">
<i class="fas fa-chevron-left me-1"></i> Anterior
</button>
<span class="mx-2">
Página <strong>@SearchParams.Page</strong> de <strong>@TotalPaginas</strong>
</span>
<button class="btn btn-secondary rounded-pill" @onclick="SiguientePagina" disabled="@(!PuedeAvanzar)">
Siguiente <i class="fas fa-chevron-right ms-1"></i>
</button>
<button class="btn btn-secondary rounded-pill" @onclick="UltimaPagina" disabled="@(SearchParams.Page == TotalPaginas)">
Última <i class="fas fa-angle-double-right ms-1"></i>
</button>
<div class="d-flex align-items-center ms-3">
<input type="number" class="form-control form-control-sm rounded" style="width: 80px;" min="1" max="@TotalPaginas" @bind="PaginaDeseada" />
<button class="btn btn-outline-primary btn-sm ms-2 rounded-pill" @onclick="IrAPagina">
<i class="fas fa-arrow-right-to-bracket me-1"></i> Ir
</button>
</div>
</div>
</div>
</div>
@code {
private PatientSearchParams SearchParams = new();
private PagedResult<EPatient>? PagedResult;
private List<Dictionary<string, object>> TablaPacientes = new();
private List<string> TableColumns = new()
{
"Id", "Nombre", "Apellido", "Documento", "#Socio | #Afiliado", "Género", "Teléfono", "Email"
};
private int PaginaDeseada = 1;
private List<PhTable.ButtonOptions> botones;
protected override void OnInitialized()
{
botones = new List<PhTable.ButtonOptions>
{
new PhTable.ButtonOptions
{
Caption = "Editar",
ElementClass = "btn btn-primary btn-sm",
UrlAction = "/sales/patientform/",
OnClickAction = async (id) =>
{
if (int.TryParse(id, out var pacienteId))
{
Navigation.NavigateTo($"/sales/patientform/{pacienteId}");
}
}
}
};
}
private async Task BuscarPacientes() => await CargarPacientes();
private async Task CargarPacientes()
{
PagedResult = await patientService.SearchPatientsAsync(SearchParams);
if (PagedResult?.Items is not null)
{
TablaPacientes = PagedResult.Items.Select(p => new Dictionary<string, object>
{
{ "Id", p.Id },
{ "Nombre", p.Firstname ?? string.Empty },
{ "Apellido", p.Lastname ?? string.Empty },
{ "Documento", $"{p.DocumenttypesId} {p.DocumentNumber}" },
{ "#Socio | #Afiliado", $"{p.AffiliateNumber}" },
{ "Género", p.Gender ?? string.Empty },
{ "Teléfono", p.Phone ?? string.Empty },
{ "Email", p.Email ?? string.Empty }
}).ToList();
}
}
private async Task PrimeraPagina() { SearchParams.Page = 1; await BuscarPacientes(); }
private async Task UltimaPagina() { SearchParams.Page = TotalPaginas; await BuscarPacientes(); }
private async Task SiguientePagina() => await CambiarPagina(1);
private async Task AnteriorPagina() => await CambiarPagina(-1);
private async Task CambiarPagina(int delta)
{
var nuevaPagina = SearchParams.Page + delta;
if (nuevaPagina >= 1 && nuevaPagina <= TotalPaginas)
{
SearchParams.Page = nuevaPagina;
await BuscarPacientes();
}
}
private async Task IrAPagina()
{
if (PaginaDeseada >= 1 && PaginaDeseada <= TotalPaginas)
{
SearchParams.Page = PaginaDeseada;
await BuscarPacientes();
}
else
{
toastService.ShowWarning("Número de página fuera de rango.");
}
}
private async Task ExportarExcel()
{
var searchParams = new PatientSearchParams
{
Name = SearchParams.Name,
Document = SearchParams.Document,
Page = 1,
PageSize = int.MaxValue
};
try
{
await patientService.ExportFilteredAsync(searchParams);
toastService.ShowSuccess("Exportación completada exitosamente.");
}
catch (Exception ex)
{
toastService.ShowError($"{ex.Message}");
}
}
private void NuevoPaciente() => Navigation.NavigateTo("/sales/patientform/");
private void Volver() => Navigation.NavigateTo("/DashboardPanel");
private bool PuedeRetroceder => PagedResult != null && SearchParams.Page > 1;
private bool PuedeAvanzar => PagedResult != null && SearchParams.Page < TotalPaginas;
private int TotalPaginas => PagedResult is null ? 1 :
(int)Math.Ceiling((double)(PagedResult.TotalItems) / SearchParams.PageSize);
}

View File

@ -42,6 +42,8 @@ builder.Services.AddScoped<TicketsService>();
builder.Services.AddScoped<CustomerService>(); builder.Services.AddScoped<CustomerService>();
builder.Services.AddScoped<TaxConditionService>(); builder.Services.AddScoped<TaxConditionService>();
builder.Services.AddScoped<AccountTypeService>(); builder.Services.AddScoped<AccountTypeService>();
builder.Services.AddScoped<PatientService>();
builder.Services.AddScoped<DocumentTypeService>();
builder.Services.AddScoped<ProductService>(); builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<BusinessUnitService>(); builder.Services.AddScoped<BusinessUnitService>();
builder.Services.AddScoped<ProductCategoryService>(); builder.Services.AddScoped<ProductCategoryService>();

View File

@ -0,0 +1,21 @@
using Domain.Entities;
using System.Net.Http.Json;
namespace phronCare.UIBlazor.Services.Sales
{
public class DocumentTypeService
{
private readonly HttpClient _httpClient;
public DocumentTypeService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<EDocumentType>> GetAllAsync()
{
var result = await _httpClient.GetFromJsonAsync<List<EDocumentType>>("/api/DocumentType/GetAll");
return result ?? new List<EDocumentType>();
}
}
}

View File

@ -0,0 +1,82 @@
using Domain.Entities;
using Domain.Generics;
using Domain.SearchParams;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text;
using Microsoft.JSInterop;
using System.Reflection;
namespace phronCare.UIBlazor.Services.Sales
{
public class PatientService
{
private readonly HttpClient _http;
private readonly IJSRuntime _js;
public PatientService(HttpClient http, IJSRuntime js)
{
_http = http;
_js = js;
}
public async Task<List<EPatient>> GetAllAsync()
{
var result = await _http.GetFromJsonAsync<List<EPatient>>("/api/Patient/all");
return result ?? new List<EPatient>();
}
public async Task<EPatient> GetByIdAsync(int id)
{
var result = await _http.GetFromJsonAsync<EPatient>($"/api/Patient/{id}");
return result ?? new EPatient();
}
public async Task<HttpResponseMessage> CreateAsync(EPatient patient)
{
return await _http.PostAsJsonAsync("/api/Patient/create", patient);
}
public async Task<HttpResponseMessage> UpdateAsync(EPatient patient)
{
return await _http.PutAsJsonAsync("/api/Patient/update", patient);
}
public async Task<PagedResult<EPatient>?> SearchPatientsAsync(PatientSearchParams searchParams)
{
var url = $"api/Patient/search?" +
$"name={searchParams.Name}&" +
$"document={searchParams.Document}&" +
$"page={searchParams.Page}&" +
$"pageSize={searchParams.PageSize}";
return await _http.GetFromJsonAsync<PagedResult<EPatient>>(url);
}
public async Task ExportFilteredAsync(PatientSearchParams searchParams)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(searchParams), Encoding.UTF8, "application/json");
var response = await _http.PostAsync("api/Patient/exportfiltered", content);
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}_pacientes.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);
}
}
}
}

View File

@ -60,7 +60,7 @@
<span class="oi oi-briefcase" aria-hidden="true"></span> <span class="oi oi-briefcase" aria-hidden="true"></span>
@if (!navMenuService.Minimized) @if (!navMenuService.Minimized)
{ {
<label>Clientes</label> <label>Ventas</label>
} }
</NavLink> </NavLink>
@if (expClientes) @if (expClientes)
@ -68,12 +68,12 @@
<ul class="nav-flex-column"> <ul class="nav-flex-column">
<div class="nav-item px-1"> <div class="nav-item px-1">
<NavLink class="nav-link" href="sales/customers/"> <NavLink class="nav-link" href="sales/customers/">
<li aria-hidden="true"></li> Búsqueda <li aria-hidden="true"></li> Clientes
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-1"> <div class="nav-item px-1">
<NavLink class="nav-link" href="sales/customerform/"> <NavLink class="nav-link" href="sales/patients/">
<li aria-hidden="true"></li> Crear <li aria-hidden="true"></li> Pacientes
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-1"> <div class="nav-item px-1">