All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 14m58s
524 lines
25 KiB
Plaintext
524 lines
25 KiB
Plaintext
@page "/quote/create"
|
|
@using System.Globalization;
|
|
@using System.Net.Http.Json
|
|
@using Blazored.Typeahead
|
|
@using Pages.Sales.Modals
|
|
@using Services.Lookups
|
|
@using Services.Integrations
|
|
@using Services.Sales.Quotes
|
|
@using Blazored.Toast.Services
|
|
@using Blazored.Toast.Configuration
|
|
|
|
@inject NavigationManager Navigation
|
|
@inject ISalesLookupService SalesLookupService
|
|
@inject IExchangeRateService ExchangeRateService
|
|
@inject QuoteService QuoteService
|
|
@inject IToastService toastService
|
|
@inject IModalService Modal
|
|
|
|
<EditForm Model="_quoteModel" >
|
|
<div class="container mt-4" style="zoom:.8;">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-center align-items-center">
|
|
<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 for="clientes">Cliente *</label>
|
|
<BlazoredTypeahead id="clientes" 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 for="vendedores">Vendedor *</label>
|
|
<BlazoredTypeahead id="vendedores" 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 for="profesionales">Profesional *</label>
|
|
<BlazoredTypeahead id="profesionales" 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 for="pacientes">Paciente *</label>
|
|
<BlazoredTypeahead id="pacientes" 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" @onclick="AddNewPatient">+</button>
|
|
</div>
|
|
</div>
|
|
<!-- FILA 3: Institución, Unidad de negocio -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6 add-wrapper">
|
|
<label for="instituciones">Institución *</label>
|
|
<BlazoredTypeahead id="instituciones" 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 for="unidad">Unidad de negocio *</label>
|
|
<InputSelect id="unidad" 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 id="moneda">Moneda</label>
|
|
<InputSelect id="moneda" 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 id="cambio">Tipo de cambio</label>
|
|
<InputNumber id="cambio" 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 id="despacho">Instrucciones de despacho</label>
|
|
<InputText id="despacho" class="form-control" @bind-Value="_quoteModel.DispatchInstruction" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label id="observaciones">Observaciones</label>
|
|
<InputText id="observaciones" class="form-control" @bind-Value="_quoteModel.Observations" />
|
|
</div>
|
|
</div>
|
|
<!-- TABLA DETALLE: 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: 5%; align-items:center">Producto</th>
|
|
<th>Descripción</th>
|
|
<th style="width: 8%; text-align:center;">Cantidad</th>
|
|
<th style="width: 15%;text-align:center;">Precio Unitario</th>
|
|
<th style="width: 10%;text-align:center;">Total</th>
|
|
<th style="width: 5%;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@if (_quoteModel.PhSQuoteDetails.Any())
|
|
{
|
|
foreach (var item in _quoteModel.PhSQuoteDetails)
|
|
{
|
|
<tr>
|
|
<td style="text-align:center">@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 style="text-align:center;">
|
|
<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" @onclick="Cancel">Cancelar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
|
|
@code {
|
|
private EQuoteHeader _quoteModel = new();
|
|
private ELookUpItem? _selectedCustomer, _selectedProfessional, _selectedInstitution, _selectedPatient, _selectedPerson;
|
|
private List<ELookUpItem> _businessUnits = new();
|
|
private EExchangeRateHistory? YesterdayRate;
|
|
private string returnUrl = "quotes/";
|
|
private decimal _netAmount = 0, _taxAmount = 0, _grandTotal = 0;
|
|
|
|
public const int QuoteSeriesId = 1; // Serie de comprobante para presupuestos (talonario Q).
|
|
|
|
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 AddNewPatient()
|
|
{
|
|
var options = new ModalOptions()
|
|
{
|
|
Size = ModalSize.Large,
|
|
HideHeader = true
|
|
};
|
|
var modal = Modal.Show<PatientQuickAddModal>(options);
|
|
var result = await modal.Result;
|
|
if (!result.Cancelled && result.Data is ELookUpItem nuevo)
|
|
{
|
|
_selectedPatient = nuevo;
|
|
}
|
|
}
|
|
|
|
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 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 async Task HandleValidSubmit()
|
|
{
|
|
var result = await QuoteService.CreateFullQuoteAsync(_quoteModel, QuoteSeriesId);
|
|
if (!result.Success)
|
|
{
|
|
toastService.ShowError(result.ErrorMessage);
|
|
return;
|
|
}
|
|
ToastParameters _parameters = new();
|
|
_parameters.Add(nameof(PhLinkToast.Comprobante), result.QuoteNumber);
|
|
_parameters.Add(nameof(PhLinkToast.Style), "success");
|
|
_parameters.Add(nameof(PhLinkToast.OnLinkClick), EventCallback.Factory.Create(this, () =>
|
|
QuoteService.ExportPdfAsync(result.Id, result.QuoteNumber)));
|
|
|
|
toastService.ShowToast<PhLinkToast>(_parameters);
|
|
Navigation.NavigateTo(returnUrl);
|
|
}
|
|
|
|
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 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)
|
|
{
|
|
_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;
|
|
}
|
|
private Task OnValueChanged<T>(EQuoteDetail item, T value, Action<EQuoteDetail, T> setter)
|
|
{
|
|
setter(item, value);
|
|
RecalculateTotals();
|
|
return Task.CompletedTask;
|
|
}
|
|
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 RemoveAdjustment(EQuoteAdjustment adj)
|
|
{
|
|
_quoteModel.PhSQuoteAdjustments.Remove(adj);
|
|
}
|
|
private void RemoveTax(EQuoteTax tax)
|
|
{
|
|
_quoteModel.PhSQuoteTaxes.Remove(tax);
|
|
RecalculateTotals();
|
|
}
|
|
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 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
|
|
{
|
|
Entitytype = entityType,
|
|
EntityId = entityId,
|
|
Role = roleName
|
|
});
|
|
}
|
|
}
|
|
private void Cancel()
|
|
{
|
|
Navigation.NavigateTo(returnUrl);
|
|
}
|
|
}
|