Estimate UI

This commit is contained in:
Paul Schneider 2026-09-13 18:54:27 +01:00
commit 027c0e1a03
19 changed files with 1235 additions and 14 deletions

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// A single billable line of an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.CommandLine</c> entity.
/// </summary>
public sealed class EstimateLineDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Count { get; set; } = 1;
public decimal UnitaryCost { get; set; }
public long EstimateId { get; set; }
public string Currency { get; set; } = "EUR";
}
/// <summary>
/// Estimate payload exchanged with the <c>api/v1/estimate</c> routes
/// (<c>EstimateApiController</c>). <see cref="AttachedGraphics"/> and
/// <see cref="AttachedFiles"/> are always initialised: the server-side
/// entity reads them from non-nullable string properties and a null
/// list would break its serialisation.
/// </summary>
public sealed class EstimateDto
{
public long Id { get; set; }
public long? CommandId { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<EstimateLineDto> Bill { get; set; } = new();
public List<string> AttachedGraphics { get; set; } = new();
public List<string> AttachedFiles { get; set; } = new();
public string? OwnerId { get; set; }
public string ClientId { get; set; } = string.Empty;
public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; }
}
/// <summary>
/// Response of a successful estimate creation
/// (<c>Ok(new { estimate.Id, estimate.Bill })</c>).
/// </summary>
public sealed class EstimateCreatedDto
{
public long Id { get; set; }
public List<EstimateLineDto> Bill { get; set; } = new();
}

View file

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the estimate routes (<c>api/v1/estimate</c>,
/// served by <c>EstimateApiController</c>). Follows the same
/// DTO↔path mapper shape as <see cref="BillingApiClient"/>: all
/// transport concerns (base URL, JSON, Bearer auth, silent refresh
/// on 401) are delegated to <see cref="IYavscApiClient"/>.
/// </summary>
public sealed class EstimateApiClient
{
private const string PathPrefix = "estimate";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public EstimateApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public EstimateApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
// Validate initial value early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Lists the estimates of the given owner; when <paramref name="ownerId"/>
/// is null, the server falls back to the current user.
/// </summary>
public Task<List<EstimateDto>> GetEstimatesAsync(string? ownerId = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(ownerId)
? PathPrefix
: $"{PathPrefix}?ownerId={Uri.EscapeDataString(ownerId)}";
return _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<EstimateDto> GetEstimateAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
/// <summary>
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
/// the server also stamps the linked command as validated.
/// </summary>
public async Task<EstimateCreatedDto> CreateAsync(EstimateDto estimate, CancellationToken ct = default)
{
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
var created = await _api.CallAsync<EstimateCreatedDto>(
HttpMethod.Post,
Absolute(PathPrefix),
body: estimate,
ct: ct).ConfigureAwait(false);
return created ?? new EstimateCreatedDto();
}
public Task UpdateAsync(long id, EstimateDto estimate, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
estimate.Id = id;
return _api.CallAsync(
HttpMethod.Put,
Absolute($"{PathPrefix}/{id}"),
body: estimate,
ct: ct);
}
public Task DeleteAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}