feat(ui): add sales document backoffice foundation #67
@ -134,6 +134,11 @@
|
|||||||
<i class="bi bi-truck me-2 text-warning"></i> Remitos
|
<i class="bi bi-truck me-2 text-warning"></i> Remitos
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item ps-4 py-0 border-start border-2 border-white">
|
||||||
|
<NavLink class="nav-link small py-0 px-2 text-start d-flex align-items-center" href="salesdocuments" activeClass="bg-secondary text-white fw-semibold">
|
||||||
|
<i class="bi bi-receipt-cutoff me-2 text-success"></i> Sales Documents
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
<div class="nav-item ps-4 py-0 border-start border-2 border-white">
|
<div class="nav-item ps-4 py-0 border-start border-2 border-white">
|
||||||
<NavLink class="nav-link small py-0 px-2 text-start d-flex align-items-center" href="sales/institutions/" activeClass="bg-secondary text-white fw-semibold">
|
<NavLink class="nav-link small py-0 px-2 text-start d-flex align-items-center" href="sales/institutions/" activeClass="bg-secondary text-white fw-semibold">
|
||||||
<i class="bi bi-building me-2 text-info"></i> Instituciones
|
<i class="bi bi-building me-2 text-info"></i> Instituciones
|
||||||
|
|||||||
@ -0,0 +1,477 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
@ -0,0 +1,270 @@
|
|||||||
|
@page "/salesdocuments/{Id:int}"
|
||||||
|
@using Domain.Constants
|
||||||
|
@using Domain.Dtos.Sales
|
||||||
|
@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="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="mb-0">Sales Document</h3>
|
||||||
|
<button type="button" class="btn btn-secondary rounded-pill" @onclick="BackToList">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> Volver
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm"><div class="card-body text-center text-muted py-4">Cargando...</div></div>
|
||||||
|
}
|
||||||
|
else if (Document is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">No se pudo cargar el Sales Document.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">@(string.IsNullOrWhiteSpace(Document.InternalDocumentNumber) ? $"#{Document.Id}" : Document.InternalDocumentNumber)</h5>
|
||||||
|
<span class="badge @GetStatusBadge(Document.Status)">@GetStatusLabel(Document.Status)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold mb-1">Fecha</label>
|
||||||
|
<div class="form-control bg-white">@FormatDate(Document.IssueDate)</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold mb-1">Tipo</label>
|
||||||
|
<div class="form-control bg-white">@GetDocumentTypeLabel(Document.DocumentType)</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold mb-1">Origen</label>
|
||||||
|
<div class="form-control bg-white">@GetOriginSummary()</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold mb-1">Total</label>
|
||||||
|
<div class="form-control bg-white">@Document.Currency @Document.TotalAmount.ToString("N2")</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Cliente</label>
|
||||||
|
<div class="form-control bg-white">@Document.CustomerName</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Facturar a</label>
|
||||||
|
<div class="form-control bg-white">@Document.BillToCustomerName</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Presupuesto</label>
|
||||||
|
<div class="form-control bg-white">@(Document.QuoteId?.ToString() ?? "—")</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Neto</label>
|
||||||
|
<div class="form-control bg-white">@Document.NetAmount.ToString("N2")</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Impuestos</label>
|
||||||
|
<div class="form-control bg-white">@Document.TaxAmount.ToString("N2")</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold mb-1">Cotización</label>
|
||||||
|
<div class="form-control bg-white">@Document.ExchangeRate.ToString("N4")</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold mb-1">Observaciones</label>
|
||||||
|
<div class="form-control bg-white">@(string.IsNullOrWhiteSpace(Document.Observations) ? "—" : Document.Observations)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header"><h5 class="mb-0">Coverage</h5></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Presupuesto</th>
|
||||||
|
<th>Quote detail</th>
|
||||||
|
<th>Porcentaje</th>
|
||||||
|
<th>Importe</th>
|
||||||
|
<th>Desde</th>
|
||||||
|
<th>Hasta</th>
|
||||||
|
<th>Notas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Document.Coverage.Any())
|
||||||
|
{
|
||||||
|
@foreach (var coverage in Document.Coverage)
|
||||||
|
{
|
||||||
|
<tr class="text-center">
|
||||||
|
<td>@GetCoverageTypeLabel(coverage.CoverageType)</td>
|
||||||
|
<td>@coverage.QuoteId</td>
|
||||||
|
<td>@(coverage.QuoteDetailId?.ToString() ?? "—")</td>
|
||||||
|
<td>@(coverage.CoveragePercentage?.ToString("N2") ?? "—")</td>
|
||||||
|
<td>@(coverage.CoverageAmount?.ToString("N2") ?? "—")</td>
|
||||||
|
<td>@FormatDate(coverage.PeriodFrom)</td>
|
||||||
|
<td>@FormatDate(coverage.PeriodTo)</td>
|
||||||
|
<td>@(string.IsNullOrWhiteSpace(coverage.Notes) ? "—" : coverage.Notes)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">Sin coverage informado.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">Detalles</h5></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Origen</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Cantidad</th>
|
||||||
|
<th>Unitario</th>
|
||||||
|
<th>Neto</th>
|
||||||
|
<th>Impuesto</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Document.Details.Any())
|
||||||
|
{
|
||||||
|
@foreach (var item in Document.Details.OrderBy(x => x.LineNumber))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">@item.LineNumber</td>
|
||||||
|
<td class="text-center">@GetOriginTypeLabel(item.OriginType)</td>
|
||||||
|
<td>@item.Description</td>
|
||||||
|
<td class="text-end">@item.Quantity.ToString("N2")</td>
|
||||||
|
<td class="text-end">@item.UnitPrice.ToString("N2")</td>
|
||||||
|
<td class="text-end">@item.NetAmount.ToString("N2")</td>
|
||||||
|
<td class="text-end">@item.TaxAmount.ToString("N2")</td>
|
||||||
|
<td class="text-end">@item.TotalAmount.ToString("N2")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">Sin detalles.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public int Id { get; set; }
|
||||||
|
|
||||||
|
private SalesDocumentDto? Document;
|
||||||
|
private bool IsLoading;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
Document = await SalesDocumentService.GetByIdAsync(Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
toastService.ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
|
||||||
|
|
||||||
|
private string GetOriginSummary()
|
||||||
|
{
|
||||||
|
if (Document?.Details?.Any() != true)
|
||||||
|
return "—";
|
||||||
|
|
||||||
|
var origins = Document.Details
|
||||||
|
.Select(x => GetOriginTypeLabel(x.OriginType))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return origins.Count == 1 ? origins[0] : string.Join(" / ", origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateTime? value) => value.HasValue ? value.Value.ToString("dd/MM/yyyy") : "—";
|
||||||
|
|
||||||
|
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 GetStatusLabel(int value) => Enum.IsDefined(typeof(SalesDocumentStatus), value)
|
||||||
|
? ((SalesDocumentStatus)value) switch
|
||||||
|
{
|
||||||
|
SalesDocumentStatus.Draft => "Borrador",
|
||||||
|
SalesDocumentStatus.Validated => "Validado",
|
||||||
|
SalesDocumentStatus.Issued => "Emitido",
|
||||||
|
SalesDocumentStatus.Cancelled => "Anulado",
|
||||||
|
_ => 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(string value) => value switch
|
||||||
|
{
|
||||||
|
"MANUAL" => "Manual",
|
||||||
|
"QUOTE" => "Presupuesto",
|
||||||
|
"ADJUSTMENT" => "Ajuste",
|
||||||
|
"CAPITA" => "Cápita",
|
||||||
|
"DELIVERY_NOTE" => "Remito",
|
||||||
|
_ => string.IsNullOrWhiteSpace(value) ? "—" : value
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetStatusBadge(int value) => value switch
|
||||||
|
{
|
||||||
|
(int)SalesDocumentStatus.Draft => "bg-secondary text-white",
|
||||||
|
(int)SalesDocumentStatus.Validated => "bg-info text-dark",
|
||||||
|
(int)SalesDocumentStatus.Issued => "bg-primary text-white",
|
||||||
|
(int)SalesDocumentStatus.Cancelled => "bg-danger text-white",
|
||||||
|
_ => "bg-light text-dark"
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,250 @@
|
|||||||
|
@page "/salesdocuments"
|
||||||
|
@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="card shadow-sm mb-3" style="zoom: 0.8;">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<div class="d-flex justify-content-center align-items-center">
|
||||||
|
<h3 class="card-title m-0">Consulta de Sales Documents</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pt-2 pb-0">
|
||||||
|
<div class="mb-3 row g-2 align-items-end">
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="customer">Cliente</label>
|
||||||
|
<input id="customer" @bind="Filters.CustomerText" class="form-control form-control-sm" placeholder="Nombre o código" />
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="quoteId">Presupuesto ID</label>
|
||||||
|
<InputNumber id="quoteId" @bind-Value="Filters.QuoteId" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="documentType">Tipo</label>
|
||||||
|
<select id="documentType" @bind="Filters.DocumentType" class="form-select form-select-sm">
|
||||||
|
<option value="">— Todos —</option>
|
||||||
|
@foreach (var item in DocumentTypeOptions)
|
||||||
|
{
|
||||||
|
<option value="@item.Value">@item.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="status">Estado</label>
|
||||||
|
<select id="status" @bind="Filters.Status" class="form-select form-select-sm">
|
||||||
|
<option value="">— Todos —</option>
|
||||||
|
@foreach (var item in StatusOptions)
|
||||||
|
{
|
||||||
|
<option value="@item.Value">@item.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="datefrom">Desde</label>
|
||||||
|
<InputDate id="datefrom" @bind-Value="Filters.IssueDateFrom" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<label for="dateto">Hasta</label>
|
||||||
|
<InputDate id="dateto" @bind-Value="Filters.IssueDateTo" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||||
|
<button class="btn btn-primary rounded-pill" @onclick="Search">
|
||||||
|
<i class="fas fa-binoculars me-1"></i> Buscar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary rounded-pill ms-1" @onclick="OnClear">
|
||||||
|
<i class="fas fa-eraser me-1"></i> Limpiar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success rounded-pill" @onclick="Create">
|
||||||
|
<i class="fas fa-plus me-1"></i> Nuevo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm" style="zoom:0.8;">
|
||||||
|
<div class="table-responsive" style="zoom:0.8;">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Documento</th>
|
||||||
|
<th>Emisión</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Facturar a</th>
|
||||||
|
<th>Presupuesto</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Moneda</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th style="width:80px;">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (PagedSalesDocuments?.Items?.Any() == true)
|
||||||
|
{
|
||||||
|
@foreach (var document in PagedSalesDocuments.Items)
|
||||||
|
{
|
||||||
|
<tr class="text-center">
|
||||||
|
<td>@(string.IsNullOrWhiteSpace(document.InternalDocumentNumber) ? $"#{document.Id}" : document.InternalDocumentNumber)</td>
|
||||||
|
<td>@FormatDate(document.IssueDate)</td>
|
||||||
|
<td>@document.CustomerName</td>
|
||||||
|
<td>@document.BillToCustomerName</td>
|
||||||
|
<td>@(document.QuoteId?.ToString() ?? "—")</td>
|
||||||
|
<td>@GetDocumentTypeLabel(document.DocumentType)</td>
|
||||||
|
<td><span class="badge @GetStatusBadge(document.Status)">@GetStatusLabel(document.Status)</span></td>
|
||||||
|
<td>@document.Currency</td>
|
||||||
|
<td>@document.TotalAmount.ToString("N2")</td>
|
||||||
|
<td class="text-center align-middle">
|
||||||
|
<button class="btn btn-link btn-lg p-0 text-primary ms-2" title="Ver detalle" @onclick="() => Detail(document.Id)"><i class="fas fa-eye"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (IsLoading)
|
||||||
|
{
|
||||||
|
<tr><td colspan="10" class="text-center text-muted py-4">Cargando...</td></tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr><td colspan="10" class="text-center text-muted py-4">Sin resultados</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-center align-items-center px-3 py-2 border-top">
|
||||||
|
<div class="btn-group justify-content-center">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="PrimeraPagina" disabled="@(Filters.Page == 1)">Primera</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="AnteriorPagina" disabled="@(!PuedeRetroceder)">Anterior</button>
|
||||||
|
<span class="mx-2">Página <strong>@Filters.Page</strong> de <strong>@TotalPaginas</strong></span>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="SiguientePagina" disabled="@(!PuedeAvanzar)">Siguiente</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="UltimaPagina" disabled="@(Filters.Page == TotalPaginas)">Última</button>
|
||||||
|
<div class="d-flex align-items-center ms-3">
|
||||||
|
<input type="number" class="form-control form-control-sm rounded" style="width: 80px;" min="1" max="@TotalPaginas" @bind="PaginaDeseada" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" @onclick="IrAPagina">
|
||||||
|
<i class="fas fa-arrow-right-to-bracket me-1"></i> Ir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private SalesDocumentSearchParams Filters = new() { PageSize = 10 };
|
||||||
|
private PagedResult<SalesDocumentSummaryDto>? PagedSalesDocuments;
|
||||||
|
private bool IsLoading;
|
||||||
|
private int PaginaDeseada = 1;
|
||||||
|
|
||||||
|
private static readonly List<SelectOption> DocumentTypeOptions = Enum.GetValues<SalesDocumentType>()
|
||||||
|
.Select(x => new SelectOption((int)x, GetDocumentTypeLabel((int)x)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static readonly List<SelectOption> StatusOptions = Enum.GetValues<SalesDocumentStatus>()
|
||||||
|
.Select(x => new SelectOption((int)x, GetStatusLabel((int)x)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await Search();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Search()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
PagedSalesDocuments = await SalesDocumentService.SearchAsync(Filters);
|
||||||
|
PaginaDeseada = Filters.Page;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
toastService.ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrimeraPagina() { Filters.Page = 1; await Search(); }
|
||||||
|
private async Task UltimaPagina() { Filters.Page = TotalPaginas; await Search(); }
|
||||||
|
private async Task SiguientePagina() => await CambiarPagina(1);
|
||||||
|
private async Task AnteriorPagina() => await CambiarPagina(-1);
|
||||||
|
|
||||||
|
private async Task CambiarPagina(int delta)
|
||||||
|
{
|
||||||
|
var nuevaPagina = Filters.Page + delta;
|
||||||
|
if (nuevaPagina >= 1 && nuevaPagina <= TotalPaginas)
|
||||||
|
{
|
||||||
|
Filters.Page = nuevaPagina;
|
||||||
|
await Search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task IrAPagina()
|
||||||
|
{
|
||||||
|
if (PaginaDeseada >= 1 && PaginaDeseada <= TotalPaginas)
|
||||||
|
{
|
||||||
|
Filters.Page = PaginaDeseada;
|
||||||
|
await Search();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toastService.ShowWarning("Número de página fuera de rango.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PuedeRetroceder => PagedSalesDocuments != null && Filters.Page > 1;
|
||||||
|
private bool PuedeAvanzar => PagedSalesDocuments != null && Filters.Page < TotalPaginas;
|
||||||
|
private int TotalPaginas => PagedSalesDocuments is null ? 1 : Math.Max(1, PagedSalesDocuments.TotalPages);
|
||||||
|
|
||||||
|
private void Create() => Navigation.NavigateTo("/salesdocuments/create");
|
||||||
|
private void Detail(int id) => Navigation.NavigateTo($"/salesdocuments/{id}");
|
||||||
|
|
||||||
|
private void OnClear()
|
||||||
|
{
|
||||||
|
Filters = new SalesDocumentSearchParams { PageSize = 10 };
|
||||||
|
PagedSalesDocuments = null;
|
||||||
|
PaginaDeseada = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateTime? value) => value.HasValue ? value.Value.ToString("dd/MM/yyyy") : "—";
|
||||||
|
|
||||||
|
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 GetStatusLabel(int value) => Enum.IsDefined(typeof(SalesDocumentStatus), value)
|
||||||
|
? ((SalesDocumentStatus)value) switch
|
||||||
|
{
|
||||||
|
SalesDocumentStatus.Draft => "Borrador",
|
||||||
|
SalesDocumentStatus.Validated => "Validado",
|
||||||
|
SalesDocumentStatus.Issued => "Emitido",
|
||||||
|
SalesDocumentStatus.Cancelled => "Anulado",
|
||||||
|
_ => value.ToString()
|
||||||
|
}
|
||||||
|
: value.ToString();
|
||||||
|
|
||||||
|
private static string GetStatusBadge(int value) => value switch
|
||||||
|
{
|
||||||
|
(int)SalesDocumentStatus.Draft => "bg-secondary text-white",
|
||||||
|
(int)SalesDocumentStatus.Validated => "bg-info text-dark",
|
||||||
|
(int)SalesDocumentStatus.Issued => "bg-primary text-white",
|
||||||
|
(int)SalesDocumentStatus.Cancelled => "bg-danger text-white",
|
||||||
|
_ => "bg-light text-dark"
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record SelectOption(int Value, string Label);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
using Blazored.Modal;
|
using Blazored.Modal;
|
||||||
using Blazored.Toast;
|
using Blazored.Toast;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
@ -10,6 +10,7 @@ using phronCare.UIBlazor.Services.Lookups;
|
|||||||
using phronCare.UIBlazor.Services.Sales.DeliveryNotes;
|
using phronCare.UIBlazor.Services.Sales.DeliveryNotes;
|
||||||
using phronCare.UIBlazor.Services.Sales;
|
using phronCare.UIBlazor.Services.Sales;
|
||||||
using phronCare.UIBlazor.Services.Sales.Quotes;
|
using phronCare.UIBlazor.Services.Sales.Quotes;
|
||||||
|
using phronCare.UIBlazor.Services.Sales.SalesDocuments;
|
||||||
using phronCare.UIBlazor.Services.Stock;
|
using phronCare.UIBlazor.Services.Stock;
|
||||||
using phronCare.UIBlazor.Services.Stock.Expeditions;
|
using phronCare.UIBlazor.Services.Stock.Expeditions;
|
||||||
using phronCare.UIBlazor.Services.Tickets;
|
using phronCare.UIBlazor.Services.Tickets;
|
||||||
@ -58,6 +59,7 @@ static void InjectDependencies(WebAssemblyHostBuilder builder)
|
|||||||
builder.Services.AddScoped<IExpeditionService, ExpeditionService>();
|
builder.Services.AddScoped<IExpeditionService, ExpeditionService>();
|
||||||
builder.Services.AddScoped<IQuoteService,QuoteService>();
|
builder.Services.AddScoped<IQuoteService,QuoteService>();
|
||||||
builder.Services.AddScoped<IDeliveryNoteService, DeliveryNoteService>();
|
builder.Services.AddScoped<IDeliveryNoteService, DeliveryNoteService>();
|
||||||
|
builder.Services.AddScoped<ISalesDocumentService, SalesDocumentService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<ExchangeRateService>();
|
builder.Services.AddScoped<ExchangeRateService>();
|
||||||
builder.Services.AddScoped<TicketsService>();
|
builder.Services.AddScoped<TicketsService>();
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
using Domain.Dtos.Sales;
|
||||||
|
using Domain.Generics;
|
||||||
|
|
||||||
|
namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
|
||||||
|
{
|
||||||
|
public interface ISalesDocumentService
|
||||||
|
{
|
||||||
|
Task<PagedResult<SalesDocumentSummaryDto>> SearchAsync(SalesDocumentSearchParams searchParams);
|
||||||
|
Task<SalesDocumentDto?> GetByIdAsync(int id);
|
||||||
|
Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
|
||||||
|
{
|
||||||
|
public sealed class SalesDocumentSearchParams
|
||||||
|
{
|
||||||
|
public int? CustomerId { get; set; }
|
||||||
|
public string? CustomerText { get; set; }
|
||||||
|
public int? QuoteId { get; set; }
|
||||||
|
public int? DocumentType { get; set; }
|
||||||
|
public int? Status { get; set; }
|
||||||
|
public DateTime? IssueDateFrom { get; set; }
|
||||||
|
public DateTime? IssueDateTo { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
using Domain.Dtos.Sales;
|
||||||
|
using Domain.Generics;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
|
||||||
|
{
|
||||||
|
public sealed class SalesDocumentService : ISalesDocumentService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
|
public SalesDocumentService(HttpClient http)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<SalesDocumentSummaryDto>> SearchAsync(SalesDocumentSearchParams searchParams)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(searchParams);
|
||||||
|
|
||||||
|
var queryParams = new List<string>();
|
||||||
|
|
||||||
|
void AddParam(string key, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
queryParams.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
AddParam("customerId", searchParams.CustomerId?.ToString());
|
||||||
|
AddParam("customerText", searchParams.CustomerText);
|
||||||
|
AddParam("quoteId", searchParams.QuoteId?.ToString());
|
||||||
|
AddParam("documentType", searchParams.DocumentType?.ToString());
|
||||||
|
AddParam("status", searchParams.Status?.ToString());
|
||||||
|
AddParam("issueDateFrom", searchParams.IssueDateFrom?.ToString("o"));
|
||||||
|
AddParam("issueDateTo", searchParams.IssueDateTo?.ToString("o"));
|
||||||
|
AddParam("page", searchParams.Page.ToString());
|
||||||
|
AddParam("pageSize", searchParams.PageSize.ToString());
|
||||||
|
|
||||||
|
var url = "/api/SalesDocument/search";
|
||||||
|
if (queryParams.Any())
|
||||||
|
url += "?" + string.Join("&", queryParams);
|
||||||
|
|
||||||
|
var result = await _http.GetFromJsonAsync<PagedResult<SalesDocumentSummaryDto>>(url);
|
||||||
|
return result ?? new PagedResult<SalesDocumentSummaryDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesDocumentDto?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
return await _http.GetFromJsonAsync<SalesDocumentDto>($"/api/SalesDocument/{id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/SalesDocument", request);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var serverMessage = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new Exception(string.IsNullOrWhiteSpace(serverMessage)
|
||||||
|
? "No se pudo crear el Sales Document."
|
||||||
|
: serverMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<SalesDocumentDto>();
|
||||||
|
return result ?? throw new Exception("Respuesta vacía del servidor.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user