Some checks failed
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Failing after 2m6s
529 lines
24 KiB
Plaintext
529 lines
24 KiB
Plaintext
@page "/quote/create"
|
|
@using System.Globalization;
|
|
@using System.Net.Http.Json
|
|
@using Blazored.Typeahead
|
|
@using Core.Interfaces
|
|
@using Services.Lookups
|
|
@using phronCare.UIBlazor.Pages.Sales.Modals
|
|
@using phronCare.UIBlazor.Services.Sales.Quotes
|
|
|
|
@inject ISalesLookupService SalesLookupService
|
|
@inject IQuoteService QuoteService
|
|
@inject IToastService toastService
|
|
@inject NavigationManager Navigation
|
|
@inject IModalService Modal
|
|
@inject IExchangeRateDom ExchangeRateService
|
|
|
|
<EditForm Model="_quoteModel" >
|
|
<div class="container mt-4" style="zoom:0.8;">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="mb-0">Emisión de Presupuesto</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- FILA 1: Cliente, Vendedor -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6 add-wrapper">
|
|
<label class="form-label">Cliente *</label>
|
|
<BlazoredTypeahead TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchCustomersAsync"
|
|
Value="_selectedCustomer"
|
|
ValueChanged="OnCustomerSelected"
|
|
ValueExpression="@(() => _selectedCustomer)"
|
|
MaximumSuggestions="5" Placeholder="Buscar cliente..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="customer">@customer.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="customer">@customer.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<button type="button" class="add-btn" title="Agregar nuevo">+</button>
|
|
</div>
|
|
<div class="col-md-6 add-wrapper">
|
|
<label class="form-label">Vendedor *</label>
|
|
<BlazoredTypeahead TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchPeopleAsync"
|
|
Value="_selectedPerson"
|
|
ValueChanged="OnPersonSelected"
|
|
ValueExpression="@(() => _selectedPerson)"
|
|
MaximumSuggestions="5" Placeholder="Buscar vendedor..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<button type="button" class="add-btn" title="Agregar vendedor">+</button>
|
|
</div>
|
|
</div>
|
|
<!-- FILA 2: Profesional, Paciente -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6 add-wrapper">
|
|
<label class="form-label">Profesional *</label>
|
|
<BlazoredTypeahead TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchProfessionalsAsync"
|
|
Value="_selectedProfessional"
|
|
ValueChanged="OnProfessionalSelected"
|
|
ValueExpression="@(() => _selectedProfessional)"
|
|
MaximumSuggestions="5" Placeholder="Buscar profesional..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<button type="button" class="add-btn" title="Agregar profesional">+</button>
|
|
<button type="button" class="add-btn" title="Agregar profesional" @onclick="AddNewProfessional">+</button>
|
|
|
|
</div>
|
|
<div class="col-md-6 add-wrapper">
|
|
<label class="form-label">Paciente *</label>
|
|
<BlazoredTypeahead TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchPatientsAsync"
|
|
Value="_selectedPatient"
|
|
ValueChanged="OnPatientSelected"
|
|
ValueExpression="@(() => _selectedPatient)"
|
|
MaximumSuggestions="5" Placeholder="Buscar paciente..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<button type="button" class="add-btn" title="Agregar paciente">+</button>
|
|
</div>
|
|
</div>
|
|
<!-- FILA 3: Institución, Unidad de negocio -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6 add-wrapper">
|
|
<label class="form-label">Institución *</label>
|
|
<BlazoredTypeahead TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SalesLookupService.SearchInstitutionsAsync"
|
|
Value="_selectedInstitution"
|
|
ValueChanged="OnInstitutionSelected"
|
|
ValueExpression="@(() => _selectedInstitution)"
|
|
MaximumSuggestions="5" Placeholder="Buscar institución..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
|
|
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
<button type="button" class="add-btn" title="Agregar institución">+</button>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Unidad de negocio *</label>
|
|
<InputSelect class="form-select" @bind-Value="_quoteModel.BusinessunitId">
|
|
<option disabled selected value="">Seleccione...</option>
|
|
@foreach (var unidad in _businessUnits)
|
|
{
|
|
<option value="@unidad.Id">@unidad.Nombre</option>
|
|
}
|
|
</InputSelect>
|
|
</div>
|
|
</div>
|
|
<!-- FILA 4: Moneda, Cambio, OutOfTown -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Moneda</label>
|
|
<InputSelect class="form-select" @bind-Value="_quoteModel.Currency">
|
|
<option value="">-- Seleccionar moneda --</option>
|
|
<option value="ARS">ARS</option>
|
|
<option value="USD">USD</option>
|
|
</InputSelect>
|
|
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Tipo de cambio</label>
|
|
<InputNumber class="form-control" @bind-Value="_quoteModel.Exchangerate"/>
|
|
</div>
|
|
<div class="col-md-4 d-flex align-items-end">
|
|
<div class="form-check form-switch">
|
|
<InputCheckbox id="OutOfTown" class="form-check-input" @bind-Value="_quoteModel.OutOfTown" />
|
|
<label class="form-check-label ms-2" for="OutOfTown">¿Fuera de localidad?</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- FILA 5: Instrucciones y Observaciones -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Instrucciones de despacho</label>
|
|
<InputText class="form-control" @bind-Value="_quoteModel.DispatchInstruction" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Observaciones</label>
|
|
<InputText class="form-control" @bind-Value="_quoteModel.Observations" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Productos Cotizados -->
|
|
<hr />
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h5 class="mb-3">Productos Cotizados</h5>
|
|
|
|
<button type="button" class="btn btn-outline-success mb-2" @onclick="AddNewProduct">
|
|
<i class="fas fa-cart-plus me-1"></i> Agregar producto
|
|
</button>
|
|
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-bordered table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 15%;">Producto</th>
|
|
<th>Descripción</th>
|
|
<th style="width: 10%;">Cantidad</th>
|
|
<th style="width: 15%;">Precio Unitario</th>
|
|
<th style="width: 15%;">Total</th>
|
|
<th style="width: 5%;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@if (_quoteModel.PhSQuoteDetails.Any())
|
|
{
|
|
foreach (var item in _quoteModel.PhSQuoteDetails)
|
|
{
|
|
<tr>
|
|
<td>@item.ProductId</td>
|
|
<td>
|
|
<InputTextArea class="form-control"
|
|
style="resize: vertical;"
|
|
@bind-Value="item.ProductDescription"
|
|
Rows="3" @oninput="RecalculateTotals" />
|
|
</td>
|
|
<td>
|
|
<InputNumber class="form-control"
|
|
Value="item.Quantity"
|
|
ValueChanged="(int val) => OnValueChanged(item, (int)val, (i, v) => i.Quantity = v)"
|
|
ValueExpression="@(() => item.Quantity)" />
|
|
</td>
|
|
<td>
|
|
<InputNumber class="form-control"
|
|
Value="item.Unitprice"
|
|
ValueChanged="(decimal val) =>OnValueChanged(item, (decimal)val, (i, v) => i.Unitprice = v)"
|
|
ValueExpression="@(() => item.Unitprice)" />
|
|
</td>
|
|
<td>$ @($"{item.Quantity * item.Unitprice:0.00}")</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-danger" @onclick="() => RemoveDetail(item)">🗑</button>
|
|
</td>
|
|
|
|
</tr>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<tr>
|
|
<td colspan="5"><em>No hay productos agregados.</em></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Totales + Ajustes -->
|
|
<div class="row justify-content-end mt-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label d-flex justify-content-between align-items-center">
|
|
Ajustes comerciales
|
|
<button class="btn btn-sm btn-outline-success mt-2" @onclick="OpenAdjustmentModal">
|
|
<i class="fas fa-plus me-1"></i> Agregar
|
|
</button>
|
|
</label>
|
|
@if (_quoteModel.PhSQuoteAdjustments.Any())
|
|
{
|
|
<div class="mb-2">
|
|
@foreach (var adj in _quoteModel.PhSQuoteAdjustments)
|
|
{
|
|
<span class="adjustment-tag">
|
|
@adj.ReasonCode
|
|
<button type="button" class="remove-btn" title="Eliminar" @onclick="() => RemoveAdjustment(adj)">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</button>
|
|
</span>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted">Sin ajustes.</p>
|
|
}
|
|
|
|
|
|
</div>
|
|
<!-- Impuestos -->
|
|
<div class="col-md-4">
|
|
<label class="form-label d-flex justify-content-between align-items-center">
|
|
Impuestos
|
|
<button class="btn btn-sm btn-outline-success mt-2" @onclick="AddNewTax">
|
|
<i class="fas fa-percentage me-1"></i> Agregar
|
|
</button>
|
|
</label>
|
|
|
|
@if (_quoteModel.PhSQuoteTaxes.Any())
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-bordered mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Nombre</th>
|
|
<th class="text-end">%</th>
|
|
<th class="text-end">Importe</th>
|
|
<th style="width: 30px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var tax in _quoteModel.PhSQuoteTaxes)
|
|
{
|
|
<tr>
|
|
<td>@tax.Taxname</td>
|
|
<td class="text-end">@tax.Taxrate.ToString("0.##")%</td>
|
|
<td class="text-end">$@tax.Taxamount.ToString("N2")</td>
|
|
<td class="text-center">
|
|
<button class="btn btn-sm btn-danger" @onclick="() => RemoveTax(tax)">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted"><em>No hay impuestos aplicados</em></p>
|
|
}
|
|
</div>
|
|
<div class="col-md-4">
|
|
<ul class="list-group">
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<span>Subtotal</span>
|
|
<strong>$ @_netAmount.ToString("N2")</strong>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<span>Taxes</span>
|
|
<strong>$ @_taxAmount.ToString("N2")</strong>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<span>Total</span>
|
|
<strong>$ @_grandTotal.ToString("N2")</strong>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div class="card-footer text-end">
|
|
<button type="button" class="btn btn-primary" @onclick="HandleValidSubmit">Guardar</button>
|
|
<button type="button" class="btn btn-secondary ms-2">Cancelar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
|
|
@code {
|
|
private EQuoteHeader _quoteModel = new();
|
|
private ELookUpItem? _selectedCustomer;
|
|
private ELookUpItem? _selectedProfessional;
|
|
private ELookUpItem? _selectedInstitution;
|
|
private ELookUpItem? _selectedPatient;
|
|
private ELookUpItem? _selectedPerson;
|
|
private List<ELookUpItem> _businessUnits = new();
|
|
private decimal _netAmount = 0;
|
|
private decimal _taxAmount = 0;
|
|
private decimal _grandTotal = 0;
|
|
private EExchangeRateHistory? YesterdayRate;
|
|
private Task OnValueChanged<T>(EQuoteDetail item, T value, Action<EQuoteDetail, T> setter)
|
|
{
|
|
setter(item, value);
|
|
RecalculateTotals();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// 1. Levantamos la cotización de ayer
|
|
try
|
|
{
|
|
YesterdayRate = await ExchangeRateService.GetYesterdayRateAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// manejar error (toast, log, etc.)
|
|
Console.Error.WriteLine(ex.Message);
|
|
}
|
|
|
|
// 2. Asignamos al modelo si aún no tiene valor
|
|
if (YesterdayRate != null && _quoteModel.Exchangerate == 0)
|
|
{
|
|
_quoteModel.Exchangerate = YesterdayRate.Salerate;
|
|
}
|
|
_businessUnits = (await SalesLookupService.SearchBussinessUnitsAsync(string.Empty)).ToList();
|
|
}
|
|
private async Task AddNewProduct()
|
|
{
|
|
var options = new ModalOptions()
|
|
{
|
|
Size = ModalSize.Large,
|
|
HideHeader = true
|
|
};
|
|
var modal = Modal.Show<ProductSelectorModal>("", options);
|
|
var result = await modal.Result;
|
|
if (!result.Cancelled && result.Data is EProductLookupItem selected)
|
|
{
|
|
var newDetail = new EQuoteDetail
|
|
{
|
|
ProductId = selected.Id,
|
|
ProductDescription = selected.Description,
|
|
Quantity = 1,
|
|
Unitprice = selected.UnitPrice,
|
|
Approved = false,
|
|
Createdat = DateTime.Now
|
|
};
|
|
_quoteModel.PhSQuoteDetails.Add(newDetail);
|
|
}
|
|
}
|
|
private async Task AddNewProfessional()
|
|
{
|
|
var options = new ModalOptions()
|
|
{
|
|
Size = ModalSize.Large,
|
|
HideHeader = true
|
|
};
|
|
var modal = Modal.Show<ProfessionalQuickAddModal>(options);
|
|
var result = await modal.Result;
|
|
if (!result.Cancelled && result.Data is ELookUpItem nuevo)
|
|
{
|
|
_selectedProfessional = nuevo;
|
|
// No hay ProfessionalId en el modelo base, pero si lo usás, actualizalo aquí.
|
|
// _quoteModel.ProfessionalId = nuevo.Id;
|
|
}
|
|
}
|
|
private Task OnCustomerSelected(ELookUpItem item)
|
|
=> SetLookupSelection(item, sel => _selectedCustomer = sel, id => _quoteModel.CustomerId = id);
|
|
private Task OnPersonSelected(ELookUpItem item)
|
|
=> SetLookupSelection(item, sel => _selectedPerson = sel, id => _quoteModel.PeopleId = id);
|
|
// private Task OnProfessionalSelected(ELookUpItem item)
|
|
// => SetLookupSelection(item, sel => _selectedProfessional = sel);
|
|
// private Task OnInstitutionSelected(ELookUpItem item)
|
|
// => SetLookupSelection(item, sel => _selectedInstitution = sel);
|
|
// private Task OnPatientSelected(ELookUpItem item)
|
|
// => SetLookupSelection(item, sel => _selectedPatient = sel);
|
|
private Task SetLookupSelection(ELookUpItem? item, Action<ELookUpItem?> setSelected, Action<int>? setModelId = null)
|
|
{
|
|
setSelected(item);
|
|
if (item != null && setModelId != null) setModelId(item.Id);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
private void OnDetailChanged(EQuoteDetail item) { }
|
|
private void RemoveDetail(EQuoteDetail item)
|
|
=> _quoteModel.PhSQuoteDetails.Remove(item);
|
|
private void RecalculateTotals()
|
|
{
|
|
_netAmount = _quoteModel.PhSQuoteDetails.Sum(d => d.Quantity * d.Unitprice);
|
|
_taxAmount = _quoteModel.PhSQuoteTaxes.Sum(t => t.Taxamount);
|
|
_grandTotal = _netAmount + _taxAmount;
|
|
_quoteModel.Netamount = _netAmount;
|
|
_quoteModel.Total = _grandTotal;
|
|
}
|
|
|
|
private async Task OpenAdjustmentModal()
|
|
{
|
|
var modal = Modal.Show<QuoteAdjustmentQuickAddModal>("Agregar Ajuste", new ModalOptions { HideHeader = true });
|
|
var result = await modal.Result;
|
|
|
|
if (!result.Cancelled && result.Data is QuoteAdjustmentQuickAddModal.QuoteAdjustmentDto dto)
|
|
{
|
|
_quoteModel.PhSQuoteAdjustments.Add(new EQuoteAdjustment
|
|
{
|
|
ReasonCode = dto.ReasonCode,
|
|
Amount = dto.Amount
|
|
});
|
|
}
|
|
}
|
|
|
|
private void RemoveAdjustment(EQuoteAdjustment adj)
|
|
{
|
|
_quoteModel.PhSQuoteAdjustments.Remove(adj);
|
|
}
|
|
private async Task AddNewTax()
|
|
{
|
|
var parameters = new ModalParameters();
|
|
parameters.Add(nameof(QuoteTaxQuickAddModal.NetAmount), _netAmount);
|
|
|
|
|
|
var options = new ModalOptions
|
|
{
|
|
HideHeader = true,
|
|
Size = ModalSize.Small
|
|
};
|
|
|
|
var modal = Modal.Show<QuoteTaxQuickAddModal>("", parameters, options);
|
|
var result = await modal.Result;
|
|
|
|
if (!result.Cancelled && result.Data is EQuoteTax newTax)
|
|
{
|
|
_quoteModel.PhSQuoteTaxes.Add(newTax);
|
|
RecalculateTotals();
|
|
}
|
|
}
|
|
private void RemoveTax(EQuoteTax tax)
|
|
{
|
|
_quoteModel.PhSQuoteTaxes.Remove(tax);
|
|
RecalculateTotals();
|
|
}
|
|
|
|
private async Task HandleValidSubmit()
|
|
{
|
|
// Si necesitas validar algo extra antes de llamar al servicio, hazlo aquí
|
|
int selectedSeriesId = 1/* obtenlo de donde corresponda, p.ej. de un dropdown */;
|
|
|
|
var result = await QuoteService.CreateFullQuoteAsync(_quoteModel, selectedSeriesId);
|
|
if (!result.Success)
|
|
{
|
|
toastService.ShowError(result.ErrorMessage);
|
|
return;
|
|
}
|
|
|
|
toastService.ShowSuccess($"Presupuesto creado: {result.QuoteNumber}");
|
|
//Navigation.NavigateTo($"/sales/quotes/details/{result.QuoteNumber}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Agrega o actualiza un rol dentro de _quoteModel.PhSQuoteRoles
|
|
/// </summary>
|
|
private void AddOrUpdateRole(string entityType, int entityId, string roleName)
|
|
{
|
|
var existing = _quoteModel.PhSQuoteRoles
|
|
.FirstOrDefault(r => r.Entitytype == entityType);
|
|
if (existing != null)
|
|
{
|
|
existing.EntityId = entityId;
|
|
existing.Role = roleName;
|
|
}
|
|
else
|
|
{
|
|
_quoteModel.PhSQuoteRoles.Add(new EQuoteRole
|
|
{
|
|
// QuoteheaderId lo llenará EF en el servidor
|
|
Entitytype = entityType,
|
|
EntityId = entityId,
|
|
Role = roleName
|
|
});
|
|
}
|
|
}
|
|
private Task OnProfessionalSelected(ELookUpItem item)
|
|
{
|
|
_selectedProfessional = item;
|
|
AddOrUpdateRole("PhS_Professionals", item.Id, "Medico");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnInstitutionSelected(ELookUpItem item)
|
|
{
|
|
_selectedInstitution = item;
|
|
AddOrUpdateRole("PhS_Institutions", item.Id, "Hospital");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnPatientSelected(ELookUpItem item)
|
|
{
|
|
_selectedPatient = item;
|
|
AddOrUpdateRole("PhS_Patients", item.Id, "Paciente");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
}
|