leandro 1fcd31080b
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 9m37s
feat(sales): refine sales document creation from delivery notes
close #68
2026-06-06 13:54:41 -03:00

264 lines
10 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="Número 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 pendientes</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">Ítems</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="args => ToggleSelection(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">Buscá remitos emitidos pendientes de facturación.</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 (SelectedCandidates.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 />@SelectedCandidates.Count
</div>
<div class="col-md-3">
<strong>Total:</strong><br />@SelectedTotal.ToString("N2")
</div>
<div class="col-md-3">
<strong>Validación:</strong><br />
@if (HasSingleFiscalCustomer)
{
<span class="badge bg-success">Cliente único</span>
}
else
{
<span class="badge bg-danger">Clientes fiscales distintos</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">Seleccioná uno o más remitos 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 IsSaving;
private PagedResult<SalesDocumentDeliveryNoteCandidateDto> Candidates = new();
private readonly HashSet<int> SelectedIds = new();
private readonly Dictionary<int, SalesDocumentDeliveryNoteCandidateDto> SelectedMap = new();
private List<SalesDocumentDeliveryNoteCandidateDto> SelectedCandidates => SelectedMap.Values.OrderBy(x => x.IssueDate).ThenBy(x => x.Id).ToList();
private bool HasSingleFiscalCustomer => SelectedCandidates.Select(x => x.CustomerId).Distinct().Count() <= 1;
private bool CanCreate => SelectedIds.Count > 0 && HasSingleFiscalCustomer && SelectedTotal > 0;
private decimal SelectedTotal => SelectedCandidates.Sum(x => x.ApprovedAmount);
private string SelectedCustomerLabel => SelectedCandidates.FirstOrDefault()?.CustomerName ?? "-";
private async Task SearchCandidatesAsync()
{
try
{
IsLoading = true;
Candidates = await SalesDocumentService.SearchDeliveryNoteCandidatesAsync(
null,
CustomerText,
DeliveryNoteNumber,
QuoteId,
IssueDateFrom,
IssueDateTo,
1,
50);
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsLoading = false;
}
}
private void ToggleSelection(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);
}
}
private void ClearFilters()
{
CustomerText = null;
DeliveryNoteNumber = null;
QuoteId = null;
IssueDateFrom = null;
IssueDateTo = null;
Candidates = new PagedResult<SalesDocumentDeliveryNoteCandidateDto>();
SelectedIds.Clear();
SelectedMap.Clear();
}
private async Task CreateAsync()
{
if (!CanCreate)
{
toastService.ShowError("Debe seleccionar remitos pendientes de un único cliente fiscal.");
return;
}
try
{
IsSaving = true;
var created = await SalesDocumentService.CreateFromDeliveryNotesAsync(new SalesDocumentCreateFromDeliveryNotesRequest
{
DeliveryNoteIds = SelectedIds.ToList(),
DocumentType = (int)SalesDocumentType.Invoice,
IssueDate = DateTime.Today,
Currency = "ARS",
ExchangeRate = 1,
Observations = Observations
});
toastService.ShowSuccess("Sales Document creado correctamente desde remitos.");
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
}
catch (Exception ex)
{
toastService.ShowError(ex.Message);
}
finally
{
IsSaving = false;
}
}
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
}