leandro a837eb41fe
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 10m11s
feat(ui): add sales document backoffice foundation
close #66
2026-06-03 21:04:49 -03:00

478 lines
21 KiB
Plaintext

@page "/salesdocuments/create"
@using System.ComponentModel.DataAnnotations
@using Blazored.Typeahead
@using Domain.Constants
@using Domain.Dtos
@using Domain.Dtos.Sales
@using Domain.Entities
@using phronCare.UIBlazor.Services.Lookups
@using phronCare.UIBlazor.Services.Sales.SalesDocuments
@inject NavigationManager Navigation
@inject ISalesDocumentService SalesDocumentService
@inject ISalesLookupService SalesLookupService
@inject IToastService toastService
<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">Nuevo Sales Document</h3>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-2">
<label for="issueDate" class="form-label">Fecha</label>
<InputDate id="issueDate" class="form-control" @bind-Value="Model.IssueDate" />
<ValidationMessage For="@(() => Model.IssueDate)" />
</div>
<div class="col-md-2">
<label for="documentType" class="form-label">Tipo documento</label>
<InputSelect id="documentType" class="form-select" @bind-Value="Model.DocumentType">
@foreach (var item in DocumentTypeOptions)
{
<option value="@item.Value">@item.Label</option>
}
</InputSelect>
</div>
<div class="col-md-2">
<label for="currency" class="form-label">Moneda</label>
<InputText id="currency" class="form-control" @bind-Value="Model.Currency" />
<ValidationMessage For="@(() => Model.Currency)" />
</div>
<div class="col-md-2">
<label for="exchangeRate" class="form-label">Cotización</label>
<InputNumber id="exchangeRate" class="form-control" @bind-Value="Model.ExchangeRate" />
</div>
<div class="col-md-4">
<label for="quoteId" class="form-label">Presupuesto ID / coverage</label>
<InputNumber id="quoteId" class="form-control" @bind-Value="Model.QuoteId" />
<ValidationMessage For="@(() => Model.QuoteId)" />
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<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 class="col-md-6">
<label for="billToCustomerLookup" class="form-label">Facturar a</label>
<BlazoredTypeahead id="billToCustomerLookup" TItem="ELookUpItem" TValue="ELookUpItem"
SearchMethod="SalesLookupService.SearchCustomersAsync"
Value="SelectedBillToCustomer"
ValueChanged="OnBillToCustomerSelected"
ValueExpression="@(() => SelectedBillToCustomer)"
MaximumSuggestions="10"
Placeholder="Buscar cliente de facturación..."
TextProperty="Nombre">
<ResultTemplate Context="item">@item.Nombre</ResultTemplate>
<SelectedTemplate Context="item">@item.Nombre</SelectedTemplate>
</BlazoredTypeahead>
<ValidationMessage For="@(() => Model.BillToCustomerId)" />
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<label for="coverageType" class="form-label">Coverage type</label>
<InputSelect id="coverageType" class="form-select" @bind-Value="Model.CoverageType">
@foreach (var item in CoverageTypeOptions)
{
<option value="@item.Value">@item.Label</option>
}
</InputSelect>
</div>
<div class="col-md-3">
<label for="coveragePercentage" class="form-label">Coverage %</label>
<InputNumber id="coveragePercentage" class="form-control" @bind-Value="Model.CoveragePercentage" />
</div>
<div class="col-md-3">
<label for="periodFrom" class="form-label">Desde</label>
<InputDate id="periodFrom" class="form-control" @bind-Value="Model.PeriodFrom" />
</div>
<div class="col-md-3">
<label for="periodTo" class="form-label">Hasta</label>
<InputDate id="periodTo" class="form-control" @bind-Value="Model.PeriodTo" />
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<label for="observations" class="form-label">Observaciones</label>
<InputTextArea id="observations" class="form-control" rows="3" @bind-Value="Model.Observations" />
</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">Detalles</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">
<thead class="table-light">
<tr>
<th style="width:60px;" class="text-center">#</th>
<th style="width:170px;">Origen</th>
<th style="width:110px;">Origin ID</th>
<th style="width:110px;">Quote detail</th>
<th>Descripción</th>
<th style="width:120px;">Cantidad</th>
<th style="width:130px;">Unitario</th>
<th style="width:130px;">Impuesto</th>
<th style="width:130px;">Total</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td class="text-center">@item.LineNumber</td>
<td>
<InputSelect class="form-select form-select-sm" @bind-Value="item.OriginType">
@foreach (var option in OriginTypeOptions)
{
<option value="@option.Value">@option.Label</option>
}
</InputSelect>
</td>
<td>
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.OriginId" />
</td>
<td>
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.QuoteDetailId" />
</td>
<td>
<InputTextArea class="form-control form-control-sm" rows="2" @bind-Value="item.Description" />
</td>
<td>
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.Quantity" />
</td>
<td>
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.UnitPrice" />
</td>
<td>
<InputNumber class="form-control form-control-sm text-end" @bind-Value="item.TaxAmount" />
</td>
<td class="text-end align-middle">@GetItemTotal(item).ToString("N2")</td>
<td class="text-center align-middle">
<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> Crear
</button>
</div>
</div>
</EditForm>
@code {
private SalesDocumentCreatePageModel Model = new()
{
IssueDate = DateTime.Today,
DocumentType = (int)SalesDocumentType.Invoice,
Currency = "ARS",
ExchangeRate = 1,
CoverageType = (int)SalesDocumentCoverageType.Manual,
CoveragePercentage = 100
};
private ELookUpItem? SelectedCustomer;
private ELookUpItem? SelectedBillToCustomer;
private List<SalesDocumentItemRow> Items = new();
private bool IsSaving;
private static readonly List<SelectOption> DocumentTypeOptions = Enum.GetValues<SalesDocumentType>()
.Select(x => new SelectOption((int)x, GetDocumentTypeLabel((int)x)))
.ToList();
private static readonly List<SelectOption> CoverageTypeOptions = Enum.GetValues<SalesDocumentCoverageType>()
.Select(x => new SelectOption((int)x, GetCoverageTypeLabel((int)x)))
.ToList();
private static readonly List<SelectOption> OriginTypeOptions = Enum.GetValues<SalesDocumentOriginType>()
.Select(x => new SelectOption((int)x, GetOriginTypeLabel(x)))
.ToList();
protected override void OnInitialized()
{
AddItem();
}
private void AddItem()
{
Items.Add(new SalesDocumentItemRow
{
LineNumber = Items.Count + 1,
OriginType = (int)SalesDocumentOriginType.Manual,
Quantity = 1,
UnitPrice = 0,
TaxAmount = 0
});
}
private void RemoveItem(SalesDocumentItemRow 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;
if (SelectedBillToCustomer is null && customer is not null)
{
SelectedBillToCustomer = customer;
Model.BillToCustomerId = customer.Id;
}
return Task.CompletedTask;
}
private Task OnBillToCustomerSelected(ELookUpItem? customer)
{
SelectedBillToCustomer = customer;
Model.BillToCustomerId = customer?.Id;
return Task.CompletedTask;
}
private string? ValidateBeforeSave()
{
if (Model.CustomerId is null or <= 0)
return "Debe seleccionar un cliente.";
if (Model.BillToCustomerId is null or <= 0)
return "Debe seleccionar un cliente de facturación.";
if (Model.QuoteId is null or <= 0)
return "Debe informar un Presupuesto ID para coverage.";
if (Items.Count == 0)
return "Debe incluir al menos un detail.";
if (Items.Any(x => string.IsNullOrWhiteSpace(x.Description)))
return "Todos los detalles deben tener descripción.";
if (Items.Any(x => x.Quantity <= 0))
return "Todos los detalles deben tener cantidad mayor a cero.";
if (Items.Any(x => x.UnitPrice < 0 || x.TaxAmount < 0))
return "Los importes no pueden ser negativos.";
if (Items.Any(x => x.OriginType != (int)SalesDocumentOriginType.Manual
&& (!x.OriginId.HasValue || x.OriginId.Value <= 0)
&& (!x.QuoteDetailId.HasValue || x.QuoteDetailId.Value <= 0)))
return "Los detalles no manuales deben informar Origin ID o Quote detail.";
if (Items.Sum(GetItemTotal) <= 0)
return "El total del documento debe ser mayor a cero.";
return null;
}
private async Task HandleValidSubmit()
{
var validationError = ValidateBeforeSave();
if (!string.IsNullOrWhiteSpace(validationError))
{
toastService.ShowError(validationError);
return;
}
try
{
IsSaving = true;
ReindexItems();
var request = new SalesDocumentCreateRequest
{
DocumentType = Model.DocumentType,
QuoteId = Model.QuoteId,
CustomerId = Model.CustomerId!.Value,
BillToCustomerId = Model.BillToCustomerId!.Value,
IssueDate = Model.IssueDate,
Currency = Model.Currency.Trim(),
ExchangeRate = Model.ExchangeRate <= 0 ? 1 : Model.ExchangeRate,
Observations = Model.Observations,
PeriodFrom = Model.PeriodFrom,
PeriodTo = Model.PeriodTo,
Details = Items.Select(x =>
{
var netAmount = GetItemNet(x);
var totalAmount = GetItemTotal(x);
return new SalesDocumentCreateDetailRequest
{
LineNumber = x.LineNumber,
OriginType = (SalesDocumentOriginType)x.OriginType,
OriginId = x.OriginId,
QuoteDetailId = x.QuoteDetailId,
Description = x.Description.Trim(),
Quantity = x.Quantity,
UnitPrice = x.UnitPrice,
NetAmount = netAmount,
TaxAmount = x.TaxAmount,
TotalAmount = totalAmount
};
}).ToList(),
Coverage = new List<SalesDocumentCreateCoverageRequest>
{
new()
{
QuoteId = Model.QuoteId!.Value,
CoverageType = Model.CoverageType,
CoveragePercentage = Model.CoveragePercentage,
CoverageAmount = Items.Sum(GetItemTotal),
PeriodFrom = Model.PeriodFrom,
PeriodTo = Model.PeriodTo,
Notes = "Coverage manual desde UI"
}
}
};
var created = await SalesDocumentService.CreateAsync(request);
toastService.ShowSuccess("Sales Document creado correctamente.");
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsSaving = false;
}
}
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
private static decimal GetItemNet(SalesDocumentItemRow item) => item.Quantity * item.UnitPrice;
private static decimal GetItemTotal(SalesDocumentItemRow item) => GetItemNet(item) + item.TaxAmount;
private static string GetDocumentTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentType), value)
? ((SalesDocumentType)value) switch
{
SalesDocumentType.Invoice => "Factura",
SalesDocumentType.DebitNote => "Nota de débito",
SalesDocumentType.CreditNote => "Nota de crédito",
SalesDocumentType.CreditInvoice => "Factura crédito",
SalesDocumentType.CreditDebitNote => "N/D crédito",
SalesDocumentType.CreditCreditNote => "N/C crédito",
_ => value.ToString()
}
: value.ToString();
private static string GetCoverageTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentCoverageType), value)
? ((SalesDocumentCoverageType)value) switch
{
SalesDocumentCoverageType.Direct => "Directa",
SalesDocumentCoverageType.Capita => "Cápita",
SalesDocumentCoverageType.Adjustment => "Ajuste",
SalesDocumentCoverageType.Manual => "Manual",
_ => value.ToString()
}
: value.ToString();
private static string GetOriginTypeLabel(SalesDocumentOriginType value) => value switch
{
SalesDocumentOriginType.Manual => "Manual",
SalesDocumentOriginType.QuoteDetail => "Presupuesto",
SalesDocumentOriginType.Adjustment => "Ajuste",
SalesDocumentOriginType.Capita => "Cápita",
SalesDocumentOriginType.DeliveryNote => "Remito",
_ => value.ToString()
};
private sealed class SalesDocumentCreatePageModel
{
[Required(ErrorMessage = "La fecha es obligatoria.")]
public DateTime? IssueDate { get; set; }
public int DocumentType { get; set; }
[Required(ErrorMessage = "El cliente es obligatorio.")]
public int? CustomerId { get; set; }
[Required(ErrorMessage = "El cliente de facturación es obligatorio.")]
public int? BillToCustomerId { get; set; }
[Required(ErrorMessage = "El presupuesto es obligatorio para coverage.")]
public int? QuoteId { get; set; }
[Required(ErrorMessage = "La moneda es obligatoria.")]
public string Currency { get; set; } = string.Empty;
public decimal ExchangeRate { get; set; }
public int CoverageType { get; set; }
public decimal? CoveragePercentage { get; set; }
public DateTime? PeriodFrom { get; set; }
public DateTime? PeriodTo { get; set; }
public string? Observations { get; set; }
}
private sealed class SalesDocumentItemRow
{
public int LineNumber { get; set; }
public int 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 decimal UnitPrice { get; set; }
public decimal TaxAmount { get; set; }
}
private sealed record SelectOption(int Value, string Label);
}