leandro 1a2478d0c7
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 22m41s
feat(sales): support delivery note item selection for partial billing close #76
2026-06-12 00:54:43 -03:00

398 lines
17 KiB
Plaintext

@page "/salesdocuments/create"
@using Domain.Constants
@using Domain.Dtos.Sales
@using Domain.Generics
@using phronCare.UIBlazor.Services.Sales.SalesDocuments
@inject NavigationManager Navigation
@inject ISalesDocumentService SalesDocumentService
@inject IToastService toastService
<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 desde remitos</h3>
</div>
<div class="card-body">
<div class="alert alert-info mb-3">
Sales Document representa el documento comercial facturable. No emite comprobante fiscal ni integra ARCA/AFIP.
</div>
<div class="row g-2 mb-3">
<div class="col-md-3">
<label class="form-label">Cliente fiscal</label>
<input class="form-control" @bind="CustomerText" placeholder="Buscar cliente..." />
</div>
<div class="col-md-3">
<label class="form-label">Remito</label>
<input class="form-control" @bind="DeliveryNoteNumber" placeholder="Numero de remito..." />
</div>
<div class="col-md-2">
<label class="form-label">Presupuesto ID</label>
<input type="number" class="form-control" @bind="QuoteId" />
</div>
<div class="col-md-2">
<label class="form-label">Desde</label>
<input type="date" class="form-control" @bind="IssueDateFrom" />
</div>
<div class="col-md-2">
<label class="form-label">Hasta</label>
<input type="date" class="form-control" @bind="IssueDateTo" />
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary rounded-pill" @onclick="ClearFilters" disabled="@IsLoading">Limpiar</button>
<button type="button" class="btn btn-primary rounded-pill" @onclick="SearchCandidatesAsync" disabled="@IsLoading">
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
Buscar remitos pendientes
</button>
</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">Remitos emitidos</h5>
<span class="badge bg-secondary">Seleccionados: @SelectedIds.Count</span>
</div>
<div class="card-body p-2">
@if (Candidates.Items.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:48px;"></th>
<th>Remito</th>
<th>Fecha</th>
<th>Cliente fiscal</th>
<th>Presupuesto</th>
<th class="text-end">Items</th>
<th class="text-end">Importe aprobado</th>
</tr>
</thead>
<tbody>
@foreach (var item in Candidates.Items)
{
<tr class="@(SelectedIds.Contains(item.Id) ? "table-primary" : string.Empty)">
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@SelectedIds.Contains(item.Id)" @onchange="async args => await ToggleSelectionAsync(item, args)" />
</td>
<td>@item.DeliveryNoteNumber</td>
<td>@item.IssueDate.ToString("dd/MM/yyyy")</td>
<td>@item.CustomerName</td>
<td>@(item.QuoteNumber ?? item.QuoteId?.ToString() ?? "-")</td>
<td class="text-end">@item.ItemCount</td>
<td class="text-end">@item.ApprovedAmount.ToString("N2")</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center text-muted py-4">Busca remitos emitidos para seleccionar sus items facturables.</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">Items facturables</h5>
<span class="badge bg-secondary">Lineas: @SelectedItemIds.Count</span>
</div>
<div class="card-body p-2">
@if (IsLoadingItems)
{
<div class="text-center text-muted py-4">
<span class="spinner-border spinner-border-sm me-2"></span>
Cargando items...
</div>
}
else if (ItemCandidates.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:48px;"></th>
<th>Remito</th>
<th>Linea</th>
<th>Descripcion</th>
<th class="text-end">Entregado</th>
<th class="text-end">Facturado</th>
<th class="text-end">Pendiente</th>
<th class="text-end" style="width:150px;">A facturar</th>
<th class="text-end">Precio</th>
<th class="text-end">Importe</th>
</tr>
</thead>
<tbody>
@foreach (var item in ItemCandidates)
{
var isSelected = SelectedItemIds.Contains(item.DeliveryNoteDetailId);
<tr class="@(isSelected ? "table-primary" : string.Empty)">
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@isSelected" @onchange="args => ToggleItemSelection(item, args)" />
</td>
<td>@item.DeliveryNoteNumber</td>
<td>@item.LineNumber</td>
<td>@item.Description</td>
<td class="text-end">@item.DeliveredQuantity.ToString("N2")</td>
<td class="text-end">@item.AlreadyBilledQuantity.ToString("N2")</td>
<td class="text-end">@item.PendingQuantity.ToString("N2")</td>
<td>
<input type="number"
class="form-control form-control-sm text-end"
min="0"
step="0.01"
max="@item.PendingQuantity"
disabled="@(!isSelected)"
@bind="item.SelectedQuantity"
@bind:event="oninput" />
</td>
<td class="text-end">@item.ApprovedUnitPrice.ToString("N2")</td>
<td class="text-end">@GetSelectedAmount(item).ToString("N2")</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center text-muted py-4">Selecciona uno o mas remitos para ver sus items con saldo pendiente.</div>
}
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header"><h5 class="mb-0">Resumen comercial</h5></div>
<div class="card-body">
@if (SelectedItems.Any())
{
<div class="row g-3 mb-3">
<div class="col-md-4">
<strong>Cliente fiscal:</strong><br />
@SelectedCustomerLabel
</div>
<div class="col-md-2">
<strong>Remitos:</strong><br />@SelectedDeliveryNoteCount
</div>
<div class="col-md-3">
<strong>Total:</strong><br />@SelectedTotal.ToString("N2")
</div>
<div class="col-md-3">
<strong>Validacion:</strong><br />
@if (HasSingleFiscalCustomer && HasValidSelectedQuantities)
{
<span class="badge bg-success">Seleccion valida</span>
}
else
{
<span class="badge bg-danger">Revisar seleccion</span>
}
</div>
</div>
<div class="mb-3">
<label class="form-label">Observaciones</label>
<textarea class="form-control" rows="3" @bind="Observations"></textarea>
</div>
}
else
{
<div class="text-muted">Selecciona items facturables para ver el resumen.</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="button" class="btn btn-primary rounded-pill" @onclick="CreateAsync" disabled="@(IsSaving || !CanCreate)">
@if (IsSaving)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
<i class="fas fa-save me-1"></i> Crear Sales Document
</button>
</div>
</div>
@code {
private string? CustomerText;
private string? DeliveryNoteNumber;
private int? QuoteId;
private DateTime? IssueDateFrom;
private DateTime? IssueDateTo;
private string? Observations;
private bool IsLoading;
private bool IsLoadingItems;
private bool IsSaving;
private PagedResult<SalesDocumentDeliveryNoteCandidateDto> Candidates = new();
private List<SalesDocumentDeliveryNoteItemCandidateDto> ItemCandidates = new();
private readonly HashSet<int> SelectedIds = new();
private readonly Dictionary<int, SalesDocumentDeliveryNoteCandidateDto> SelectedMap = new();
private readonly HashSet<int> SelectedItemIds = new();
private List<SalesDocumentDeliveryNoteCandidateDto> SelectedCandidates => SelectedMap.Values.OrderBy(x => x.IssueDate).ThenBy(x => x.Id).ToList();
private List<SalesDocumentDeliveryNoteItemCandidateDto> SelectedItems => ItemCandidates
.Where(x => SelectedItemIds.Contains(x.DeliveryNoteDetailId))
.OrderBy(x => x.DeliveryNoteIssueDate)
.ThenBy(x => x.DeliveryNoteId)
.ThenBy(x => x.LineNumber)
.ToList();
private bool HasSingleFiscalCustomer => SelectedItems.Select(x => x.CustomerId).Distinct().Count() <= 1;
private bool HasValidSelectedQuantities => SelectedItems.All(x => x.SelectedQuantity > 0 && x.SelectedQuantity <= x.PendingQuantity);
private bool CanCreate => SelectedItems.Any() && HasSingleFiscalCustomer && HasValidSelectedQuantities && SelectedTotal > 0;
private decimal SelectedTotal => SelectedItems.Sum(GetSelectedAmount);
private int SelectedDeliveryNoteCount => SelectedItems.Select(x => x.DeliveryNoteId).Distinct().Count();
private string SelectedCustomerLabel => SelectedItems.FirstOrDefault()?.CustomerName ?? "-";
private async Task SearchCandidatesAsync()
{
try
{
IsLoading = true;
Candidates = await SalesDocumentService.SearchDeliveryNoteCandidatesAsync(
null,
CustomerText,
DeliveryNoteNumber,
QuoteId,
IssueDateFrom,
IssueDateTo,
1,
50);
SelectedIds.Clear();
SelectedMap.Clear();
ItemCandidates.Clear();
SelectedItemIds.Clear();
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsLoading = false;
}
}
private async Task ToggleSelectionAsync(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args)
{
var selected = args.Value is bool value && value;
if (selected)
{
SelectedIds.Add(item.Id);
SelectedMap[item.Id] = item;
}
else
{
SelectedIds.Remove(item.Id);
SelectedMap.Remove(item.Id);
}
await LoadItemCandidatesAsync();
}
private async Task LoadItemCandidatesAsync()
{
try
{
IsLoadingItems = true;
ItemCandidates.Clear();
SelectedItemIds.Clear();
if (SelectedIds.Count == 0)
return;
ItemCandidates = await SalesDocumentService.GetDeliveryNoteItemCandidatesAsync(SelectedIds);
foreach (var item in ItemCandidates)
SelectedItemIds.Add(item.DeliveryNoteDetailId);
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsLoadingItems = false;
}
}
private void ToggleItemSelection(SalesDocumentDeliveryNoteItemCandidateDto item, ChangeEventArgs args)
{
var selected = args.Value is bool value && value;
if (selected)
SelectedItemIds.Add(item.DeliveryNoteDetailId);
else
SelectedItemIds.Remove(item.DeliveryNoteDetailId);
}
private void ClearFilters()
{
CustomerText = null;
DeliveryNoteNumber = null;
QuoteId = null;
IssueDateFrom = null;
IssueDateTo = null;
Candidates = new PagedResult<SalesDocumentDeliveryNoteCandidateDto>();
SelectedIds.Clear();
SelectedMap.Clear();
ItemCandidates.Clear();
SelectedItemIds.Clear();
}
private async Task CreateAsync()
{
if (!CanCreate)
{
toastService.ShowError("Debe seleccionar items pendientes de un unico cliente fiscal y cantidades validas.");
return;
}
try
{
IsSaving = true;
var created = await SalesDocumentService.CreateFromDeliveryNoteItemsAsync(new SalesDocumentCreateFromDeliveryNoteItemsRequest
{
Items = SelectedItems.Select(x => new SalesDocumentDeliveryNoteItemSelectionDto
{
DeliveryNoteDetailId = x.DeliveryNoteDetailId,
SelectedQuantity = x.SelectedQuantity
}).ToList(),
DocumentType = (int)SalesDocumentType.Invoice,
IssueDate = DateTime.Today,
Currency = "ARS",
ExchangeRate = 1,
Observations = Observations
});
toastService.ShowSuccess("Sales Document creado correctamente desde items de remitos.");
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsSaving = false;
}
}
private static decimal GetSelectedAmount(SalesDocumentDeliveryNoteItemCandidateDto item)
{
return item.ApprovedUnitPrice * item.SelectedQuantity;
}
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
}