All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 9m31s
Se implementa la construcción automática de ExtrainfoJson al seleccionar un presupuesto en la pantalla de emisión de Delivery Note. - Se genera snapshot clínico con Professional, Institution, Patient y SurgeryDate - Se serializa a JSON plano utilizando System.Text.Json - Se asigna a Model.ExtraInfoJson para persistencia - Se limpia el snapshot al deseleccionar o fallar la carga del presupuesto Se mantiene consistencia con el patrón implementado en Expeditions. No se modifican contratos ni capas Core/API/Data. Closes #41
424 lines
18 KiB
Plaintext
424 lines
18 KiB
Plaintext
@page "/deliverynotes/create"
|
|
@using System.ComponentModel.DataAnnotations
|
|
@using System.Text.Json
|
|
@using Blazored.Typeahead
|
|
@using Domain.Constants
|
|
@using Domain.Dtos
|
|
@using Domain.Dtos.Sales
|
|
@using phronCare.UIBlazor.Services.Lookups
|
|
@using phronCare.UIBlazor.Services.Sales.DeliveryNotes
|
|
@using phronCare.UIBlazor.Services.Sales.Quotes
|
|
@using phronCare.UIBlazor.Shared.Modals
|
|
|
|
@inject NavigationManager Navigation
|
|
@inject IDeliveryNoteService DeliveryNoteService
|
|
@inject ISalesLookupService SalesLookupService
|
|
@inject IQuoteService QuoteService
|
|
@inject IToastService toastService
|
|
@inject IModalService Modal
|
|
|
|
<EditForm Model="Model" OnValidSubmit="HandleValidSubmit">
|
|
<DataAnnotationsValidator />
|
|
<ValidationSummary />
|
|
|
|
<div class="container mt-4" style="zoom:.8;">
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header d-flex justify-content-center align-items-center">
|
|
<h3 class="mb-0">Emisión de Remito</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-2">
|
|
<label for="deliveryNoteNumber" class="form-label">Número de remito</label>
|
|
<InputText id="deliveryNoteNumber" class="form-control" @bind-Value="Model.DeliveryNoteNumber" />
|
|
<ValidationMessage For="@(() => Model.DeliveryNoteNumber)" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="issueDate" class="form-label">Fecha de emisión</label>
|
|
<InputDate id="issueDate" class="form-control" @bind-Value="Model.IssueDate" />
|
|
<ValidationMessage For="@(() => Model.IssueDate)" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="quoteLookup" class="form-label">Presupuesto aprobado (opcional)</label>
|
|
<BlazoredTypeahead id="quoteLookup" TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchApprovedQuotesAsync"
|
|
Value="SelectedQuote"
|
|
ValueChanged="OnQuoteSelected"
|
|
ValueExpression="@(() => SelectedQuote)"
|
|
MaximumSuggestions="10"
|
|
Placeholder="Buscar presupuesto aprobado..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="customerLookup" class="form-label">Cliente</label>
|
|
<BlazoredTypeahead id="customerLookup" TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchCustomersAsync"
|
|
Value="SelectedCustomer"
|
|
ValueChanged="OnCustomerSelected"
|
|
ValueExpression="@(() => SelectedCustomer)"
|
|
MaximumSuggestions="10"
|
|
Placeholder="Buscar cliente..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<ValidationMessage For="@(() => Model.CustomerId)" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label for="observations" class="form-label">Observaciones</label>
|
|
<InputTextArea id="observations" class="form-control" rows="3" @bind-Value="Model.Observations" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
@if (SelectedQuote is not null)
|
|
{
|
|
<label for="observations" class="form-label">Vinculado</label>
|
|
<div class="alert alert-dark border mb-3">
|
|
<strong rows="3">Presupuesto vinculado:</strong> @SelectedQuote.Nombre
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (SelectedQuote is not null)
|
|
{
|
|
<div class="card border-1 bg-light-subtle">
|
|
<div class="card-body py-3">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-semibold mb-1">Profesional</label>
|
|
<div class="form-control bg-white">@(string.IsNullOrWhiteSpace(ExtraInfo.Professional) ? "No informado" : ExtraInfo.Professional)</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-semibold mb-1">Institución</label>
|
|
<div class="form-control bg-white">@(string.IsNullOrWhiteSpace(ExtraInfo.Institution) ? "No informada" : ExtraInfo.Institution)</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-semibold mb-1">Paciente</label>
|
|
<div class="form-control bg-white">@(string.IsNullOrWhiteSpace(ExtraInfo.Patient) ? "No informado" : ExtraInfo.Patient)</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-semibold mb-1">Fecha estimada de cirugía</label>
|
|
<div class="form-control bg-white">@(ExtraInfo.SurgeryDate.HasValue ? ExtraInfo.SurgeryDate.Value.ToString("dd/MM/yyyy") : "No informada")</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Ítems del remito</h5>
|
|
<button type="button" class="btn btn-outline-success btn-sm rounded-pill" @onclick="AddItem">
|
|
<i class="fas fa-plus me-1"></i> Agregar ítem
|
|
</button>
|
|
</div>
|
|
<div class="card-body p-2">
|
|
@if (Items.Any())
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-bordered mb-0 deliverynote-items-table">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 60px;" class="text-center">#</th>
|
|
<th>Descripción</th>
|
|
<th style="width: 120px;" class="text-center">Cantidad</th>
|
|
<th style="width: 160px;" class="text-center">Origen</th>
|
|
<th>Notas</th>
|
|
<th style="width: 60px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var item in Items)
|
|
{
|
|
<tr>
|
|
<td class="text-center line-number-cell">@item.LineNumber</td>
|
|
<td>
|
|
<InputTextArea class="form-control form-control-sm item-description" rows="3" @bind-Value="item.Description" />
|
|
</td>
|
|
<td>
|
|
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.Quantity" />
|
|
</td>
|
|
<td>
|
|
<InputSelect class="form-select form-select-sm" @bind-Value="item.OriginType">
|
|
<option value="@((byte)DeliveryNoteItemOriginType.Manual)">Manual</option>
|
|
<option value="@((byte)DeliveryNoteItemOriginType.QuoteDetail)">Presupuesto</option>
|
|
<option value="@((byte)DeliveryNoteItemOriginType.SalesProduct)">Producto venta</option>
|
|
<option value="@((byte)DeliveryNoteItemOriginType.StockProduct)">Producto stock</option>
|
|
</InputSelect>
|
|
</td>
|
|
<td>
|
|
<InputTextArea class="form-control form-control-sm item-notes" rows="3" @bind-Value="item.Notes" />
|
|
</td>
|
|
<td class="text-center actions-cell">
|
|
<button type="button" class="btn btn-link p-0 text-danger" title="Eliminar" @onclick="() => RemoveItem(item)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="text-center text-muted py-4">
|
|
No hay ítems cargados.
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-end gap-2">
|
|
<button type="button" class="btn btn-secondary rounded-pill" @onclick="BackToList" disabled="@IsSaving">
|
|
<i class="fas fa-arrow-left me-1"></i> Volver
|
|
</button>
|
|
<button type="submit" class="btn btn-primary rounded-pill" disabled="@IsSaving">
|
|
@if (IsSaving)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
<i class="fas fa-save me-1"></i> Emitir remito
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
|
|
@code {
|
|
private DeliveryNoteCreatePageModel Model = new()
|
|
{
|
|
IssueDate = DateTime.Today
|
|
};
|
|
|
|
private ELookUpItem? SelectedCustomer;
|
|
private ELookUpItem? SelectedQuote;
|
|
private DeliveryNoteExtraInfoModel ExtraInfo = new();
|
|
private List<DeliveryNoteItemRow> Items = new();
|
|
|
|
private bool IsSaving;
|
|
|
|
private void AddItem()
|
|
{
|
|
Items.Add(new DeliveryNoteItemRow
|
|
{
|
|
LineNumber = Items.Count + 1,
|
|
OriginType = (byte)DeliveryNoteItemOriginType.Manual,
|
|
Quantity = 1
|
|
});
|
|
}
|
|
|
|
private void RemoveItem(DeliveryNoteItemRow item)
|
|
{
|
|
if (Items.Remove(item))
|
|
{
|
|
ReindexItems();
|
|
}
|
|
}
|
|
|
|
private void ReindexItems()
|
|
{
|
|
for (var i = 0; i < Items.Count; i++)
|
|
{
|
|
Items[i].LineNumber = i + 1;
|
|
}
|
|
}
|
|
|
|
private Task OnCustomerSelected(ELookUpItem? customer)
|
|
{
|
|
SelectedCustomer = customer;
|
|
Model.CustomerId = customer?.Id;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task OnQuoteSelected(ELookUpItem? quote)
|
|
{
|
|
SelectedQuote = quote;
|
|
Model.QuoteId = quote?.Id;
|
|
|
|
if (quote is null)
|
|
{
|
|
ExtraInfo = new();
|
|
Model.ExtraInfoJson = null;
|
|
return;
|
|
}
|
|
|
|
var quoteDto = await QuoteService.GetDtoByIdAsync(quote.Id);
|
|
if (quoteDto is null)
|
|
{
|
|
ExtraInfo = new();
|
|
Model.ExtraInfoJson = null;
|
|
toastService.ShowError("No se pudo cargar el presupuesto seleccionado.");
|
|
return;
|
|
}
|
|
|
|
ExtraInfo = BuildExtraInfoModel(quoteDto);
|
|
|
|
var mappedItems = BuildItemsFromApprovedQuote(quoteDto);
|
|
if (mappedItems.Count == 0)
|
|
{
|
|
Model.ExtraInfoJson = JsonSerializer.Serialize(ExtraInfo);
|
|
toastService.ShowWarning("El presupuesto seleccionado no tiene ítems aprobados para precargar.");
|
|
return;
|
|
}
|
|
|
|
if (Items.Any())
|
|
{
|
|
var parameters = new ModalParameters();
|
|
parameters.Add(nameof(ConfirmModal.Title), "Reemplazar ítems");
|
|
parameters.Add(nameof(ConfirmModal.Message), "Ya hay ítems cargados. ¿Desea reemplazarlos por los ítems aprobados del presupuesto?");
|
|
|
|
var modal = Modal.Show<ConfirmModal>("Confirmación", parameters);
|
|
var result = await modal.Result;
|
|
if (result.Cancelled)
|
|
return;
|
|
}
|
|
|
|
Items = mappedItems;
|
|
Model.ExtraInfoJson = JsonSerializer.Serialize(ExtraInfo);
|
|
ReindexItems();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private static DeliveryNoteExtraInfoModel BuildExtraInfoModel(QuoteDto quote)
|
|
{
|
|
return new DeliveryNoteExtraInfoModel
|
|
{
|
|
Professional = quote.ProfessionalName,
|
|
Institution = quote.InstitutionName,
|
|
Patient = quote.PatientName,
|
|
SurgeryDate = quote.EstimatedDate
|
|
};
|
|
}
|
|
|
|
private List<DeliveryNoteItemRow> BuildItemsFromApprovedQuote(QuoteDto quote)
|
|
{
|
|
return quote.Items
|
|
.Where(item => item.Approved)
|
|
.Select(item => new
|
|
{
|
|
Item = item,
|
|
Quantity = item.ApprovedQuantity.HasValue && item.ApprovedQuantity.Value > 0
|
|
? item.ApprovedQuantity.Value
|
|
: item.Quantity
|
|
})
|
|
.Where(x => x.Quantity > 0)
|
|
.Select((x, index) => new DeliveryNoteItemRow
|
|
{
|
|
LineNumber = index + 1,
|
|
OriginType = (byte)DeliveryNoteItemOriginType.QuoteDetail,
|
|
QuoteDetailId = x.Item.Id,
|
|
Description = x.Item.Description,
|
|
Quantity = x.Quantity
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private string? ValidateBeforeSave()
|
|
{
|
|
if (Items.Count == 0)
|
|
return "Debe incluir al menos un ítem.";
|
|
|
|
if (Items.Any(x => string.IsNullOrWhiteSpace(x.Description)))
|
|
return "Todos los ítems deben tener descripción.";
|
|
|
|
if (Items.Any(x => x.Quantity <= 0))
|
|
return "Todos los ítems deben tener cantidad mayor a cero.";
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task HandleValidSubmit()
|
|
{
|
|
var validationError = ValidateBeforeSave();
|
|
if (!string.IsNullOrWhiteSpace(validationError))
|
|
{
|
|
toastService.ShowError(validationError);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
IsSaving = true;
|
|
|
|
var request = new DeliveryNoteCreateRequest
|
|
{
|
|
DeliveryNoteNumber = Model.DeliveryNoteNumber.Trim(),
|
|
IssueDate = Model.IssueDate!.Value,
|
|
CustomerId = Model.CustomerId!.Value,
|
|
QuoteId = Model.QuoteId,
|
|
Observations = Model.Observations,
|
|
ExtraInfoJson = Model.ExtraInfoJson,
|
|
Items = Items.Select(x => new DeliveryNoteCreateItemRequest
|
|
{
|
|
OriginType = x.OriginType,
|
|
OriginId = x.OriginId,
|
|
QuoteDetailId = x.QuoteDetailId,
|
|
Description = x.Description.Trim(),
|
|
Quantity = x.Quantity,
|
|
Notes = string.IsNullOrWhiteSpace(x.Notes) ? null : x.Notes.Trim()
|
|
}).ToList()
|
|
};
|
|
|
|
var response = await DeliveryNoteService.CreateAndIssueAsync(request);
|
|
toastService.ShowSuccess($"Remito {response.DeliveryNoteNumber} emitido correctamente.");
|
|
Navigation.NavigateTo("/deliverynotes");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
toastService.ShowError(ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
IsSaving = false;
|
|
}
|
|
}
|
|
|
|
private void BackToList()
|
|
{
|
|
Navigation.NavigateTo("/deliverynotes");
|
|
}
|
|
|
|
private sealed class DeliveryNoteCreatePageModel
|
|
{
|
|
[Required(ErrorMessage = "El número de remito es obligatorio.")]
|
|
public string DeliveryNoteNumber { get; set; } = string.Empty;
|
|
|
|
[Required(ErrorMessage = "La fecha de emisión es obligatoria.")]
|
|
public DateTime? IssueDate { get; set; }
|
|
|
|
[Required(ErrorMessage = "El cliente es obligatorio.")]
|
|
public int? CustomerId { get; set; }
|
|
|
|
public int? QuoteId { get; set; }
|
|
public string? Observations { get; set; }
|
|
public string? ExtraInfoJson { get; set; }
|
|
}
|
|
|
|
private sealed class DeliveryNoteExtraInfoModel
|
|
{
|
|
public string? Professional { get; set; }
|
|
public string? Institution { get; set; }
|
|
public string? Patient { get; set; }
|
|
public DateTime? SurgeryDate { get; set; }
|
|
}
|
|
|
|
private sealed class DeliveryNoteItemRow
|
|
{
|
|
public int LineNumber { get; set; }
|
|
public byte OriginType { get; set; }
|
|
public int? OriginId { get; set; }
|
|
public int? QuoteDetailId { get; set; }
|
|
public string Description { get; set; } = string.Empty;
|
|
public decimal Quantity { get; set; }
|
|
public string? Notes { get; set; }
|
|
}
|
|
}
|