From a5eea09eee0c7fd216afdb908bbc6a931ca0dbff400157d489e430f429431dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= <93217193+Daniel-Garmig@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:31:13 +0200 Subject: [PATCH] GuestLog - Improvements to GuestDocument creation - Added DocumentNumber validations. - Added new page to see GuestDocument details. --- .../Components/Controls/GuestDocForm.razor | 68 ++++++----- .../Pages/GuestDocuments/GuestDocDetail.razor | 81 +++++++++++++ .../Pages/GuestDocuments/GuestDocNew.razor | 114 +++++++++++++++++- src/Somnia.GuestLog/GuestConsts.cs | 8 ++ .../Models/DTOs/GuestDocIdDTO.cs | 28 +++++ src/Somnia.GuestLog/Program.cs | 1 + .../Resources/LanguageLocale.resx | 7 ++ .../Services/StaticDataListService.cs | 6 +- src/Somnia.GuestLog/Utils/DNIUtils.cs | 106 ++++++++++++++++ src/Somnia.GuestLog/Utils/RegexUtils.cs | 15 +++ .../Validators/CustomValidators.cs | 14 +++ .../Validators/GuestDocIdDTOValidator.cs | 44 +++++++ .../Validators/GuestDocumentDTOValidator.cs | 15 ++- 13 files changed, 465 insertions(+), 42 deletions(-) create mode 100644 src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor create mode 100644 src/Somnia.GuestLog/Models/DTOs/GuestDocIdDTO.cs create mode 100644 src/Somnia.GuestLog/Utils/DNIUtils.cs create mode 100644 src/Somnia.GuestLog/Utils/RegexUtils.cs create mode 100644 src/Somnia.GuestLog/Validators/CustomValidators.cs create mode 100644 src/Somnia.GuestLog/Validators/GuestDocIdDTOValidator.cs diff --git a/src/Somnia.GuestLog/Components/Controls/GuestDocForm.razor b/src/Somnia.GuestLog/Components/Controls/GuestDocForm.razor index b429098..69191eb 100644 --- a/src/Somnia.GuestLog/Components/Controls/GuestDocForm.razor +++ b/src/Somnia.GuestLog/Components/Controls/GuestDocForm.razor @@ -15,29 +15,31 @@ @LanguageLocale.control_GuestDocForm_sectionDocument - + + Required ReadOnly="true"> @foreach (string docType in dataListService.GetDataListByName(GuestConsts.DataList_DocumentTypes)) { @dataListService.GetLocalizedValue(GuestConsts.DataList_DocumentTypes, docType) } + Immediate="true" Required ReadOnly="true" /> - + + Immediate="true" Required="@isSuportNumberRequired" Mask="@(new PatternMask("aaa000000"))" + OnBlur="@(() => Model.SupportNum = Model.SupportNum?.ToUpper())" /> + SelectValueOnTab="true" AdornmentIcon="@Icons.Material.Filled.Search" + style="@FieldMarginY" /> @@ -45,13 +47,17 @@ @LanguageLocale.control_GuestDocForm_sectionGuest - - - + + + - + @@ -71,10 +77,12 @@ - - + + @@ -85,16 +93,17 @@ - - - - + + + + - - + @@ -117,9 +126,10 @@ private MudForm _form = null!; + private const string FieldMarginY = "margin-block: 0.5em"; private Dictionary? countryDict = null; - private bool isSuportNumberRequired { get => Model?.Nationality == "ESP"; } + private bool isSuportNumberRequired { get => Model?.Nationality == GuestConsts.CountryCode_ESP; } private string CreateTimeString { get @@ -146,11 +156,13 @@ private async Task> SearchCountryCodes(string value, CancellationToken token) { // if text is null or empty, show complete list - if (string.IsNullOrEmpty(value)) { + if (string.IsNullOrEmpty(value)) + { return new string[0]; } - if(countryDict is null || countryDict.Count == 0) { + if (countryDict is null || countryDict.Count == 0) + { var countryCodeSet = dataListService.GetDataListByName(GuestConsts.DataList_CountryCodes); countryDict = new(countryCodeSet.Count); diff --git a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor new file mode 100644 index 0000000..1887437 --- /dev/null +++ b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor @@ -0,0 +1,81 @@ +@attribute [Route($"{GuestConsts.Page_GuestDocs_Detail}")] + +@using Somnia.GuestLog.Models.DTOs; +@using Somnia.GuestLog.Services; + +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject NavigationManager Navigation +@inject IGuestDocumentService guestDocService + + +@inject FluentValidation.IValidator docIdValidator; + + +Somnia - Guest Docs - Details + + + Guest Document - "@docId" + + + @LanguageLocale.general_btnEdit + + + @LanguageLocale.general_btnReturn + + + +@if (documentModel is not null) +{ + +} + +@code { + + [Parameter] + public string? docId { get; set; } + + GuestDocForm _form = null!; + + GuestDocumentDTO? documentModel = null; + + + protected override async Task OnInitializedAsync() + { + if (docId is null) + { + Snackbar.Add("No GuestDocument provided!!", Severity.Error); + Navigation.NotFound(); + return; + } + + GuestDocIdDTO? docIdModel = GuestDocIdDTO.DecodeFromString(docId); + if (docIdModel is null) + { + Snackbar.Add($"Provided GuestDocId can't be decoded: '{docId}'", Severity.Error); + Navigation.NotFound(); + return; + } + + //Get model from db. + documentModel = await guestDocService.GetDtoByIdAsync(docIdModel.DocumentType, docIdModel.DocumentNum); + if (documentModel is null) + { + Snackbar.Add($"GuestDocument not found with ID: '{docId}'", Severity.Error); + Navigation.NotFound(); + return; + } + } + + private void BtnEdit() + { + Snackbar.Add("Not implemented yet!", Severity.Info); + } + + private void BtnReturn() + { + Navigation.NavigateTo(GuestConsts.Page_GuestDocs_List); + } +} \ No newline at end of file diff --git a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor index 71cb331..9ba6115 100644 --- a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor +++ b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor @@ -1,15 +1,16 @@ @attribute [Route($"{GuestConsts.Page_GuestDocs_New}")] -@using Microsoft.EntityFrameworkCore; @using Somnia.GuestLog.Models.DTOs; -@using Somnia.GuestLog.Infrastructure; @using Somnia.GuestLog.Services; +@using Somnia.GuestLog.Validators; -@inject IDbContextFactory contextFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Navigation @inject IGuestDocumentService guestDocService +@inject IDataListService dataListService; + +@inject FluentValidation.IValidator docIdValidator; Somnia - Guest Docs - New @@ -26,16 +27,98 @@ @LanguageLocale.general_btnReturn - + +@if (currentPageMode == ePageMode.selectDocId || currentPageMode == ePageMode.alreadyExistDoc) +{ + + + + + + @foreach (string docType in dataListService.GetDataListByName(GuestConsts.DataList_DocumentTypes)) + { + + @dataListService.GetLocalizedValue(GuestConsts.DataList_DocumentTypes, docType) + + } + + + + + @LanguageLocale.general_btnSearch + + + + + @if (currentPageMode == ePageMode.alreadyExistDoc) + { + + + @LanguageLocale.page_GuestDocNew_msgAlreadyExists + + + @LanguageLocale.page_GuestDocNew_btnGoExistingDoc + + + } + +} + +@if (currentPageMode == ePageMode.createNew) +{ + +} @code { - GuestDocForm _form = null!; + MudForm _docIdForm = null!; + ePageMode currentPageMode = ePageMode.selectDocId; + GuestDocIdDTO currentDocId = new() { DocumentNum = "", DocumentType = "" }; + GuestDocumentDTO newDocumentModel = new(); + + bool loadingDocId = false; #region Event Handlers + /// + /// Check if given Document already exists or can be created. + /// + private async Task BtnCheckDocId() + { + await _docIdForm.ValidateAsync(); + if (!_docIdForm.IsValid) + { + return; + } + + loadingDocId = true; + GuestDocumentDTO? dto = await guestDocService.GetDtoByIdAsync(currentDocId.DocumentType, currentDocId.DocumentNum); + loadingDocId = false; + + if (dto is null) + { + //Document don't exist -> Create it. + currentPageMode = ePageMode.createNew; + + //Update model with current DocId + newDocumentModel.DocumentType = currentDocId.DocumentType; + newDocumentModel.DocumentNum = currentDocId.DocumentNum; + + return; + } + + //GuestDoc already exists! + currentPageMode = ePageMode.alreadyExistDoc; + } + private void BtnReturn() { Navigation.NavigateTo(GuestConsts.Page_GuestDocs_List); @@ -69,6 +152,27 @@ Navigation.NavigateTo(GuestConsts.Page_GuestDocs_List); } + private void BtnGoToDocument() + { + if (currentPageMode != ePageMode.alreadyExistDoc) + { + Snackbar.Add("Invalid operation!", Severity.Error); + return; + } + + string docId = GuestDocIdDTO.EncodeAsString(currentDocId); + string uri = GuestConsts.Page_GuestDocs_Detail.Replace("{docId}", docId); + Navigation.NavigateTo(uri); + } + #endregion + #region Models + + enum ePageMode + { + selectDocId, alreadyExistDoc, createNew + } + + #endregion } diff --git a/src/Somnia.GuestLog/GuestConsts.cs b/src/Somnia.GuestLog/GuestConsts.cs index c618f70..d22f412 100644 --- a/src/Somnia.GuestLog/GuestConsts.cs +++ b/src/Somnia.GuestLog/GuestConsts.cs @@ -5,6 +5,7 @@ public static class GuestConsts public const string Page_GuestDocs_List = "/guest-documents"; public const string Page_GuestDocs_New = "/guest-documents/new"; + public const string Page_GuestDocs_Detail = "/guest-documents/d/{docId}"; public const string regexPattern_phoneNum = @"\+?\d{1,4}?[-.\s]?\(?\d{1,3}?\)?[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}"; @@ -13,4 +14,11 @@ public static class GuestConsts public const string DataList_DocumentTypes = "DocumentType"; public const string DataList_GenderOptions = "GenderOption"; public const string DataList_CountryCodes = "CountryCode"; + + + public const string CountryCode_ESP = "ESP"; + + public const string DocumentType_DNI = "DNI"; + public const string DocumentType_NIE = "NIE"; + public const string DocumentType_PASSPORT = "PASSPORT"; } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Models/DTOs/GuestDocIdDTO.cs b/src/Somnia.GuestLog/Models/DTOs/GuestDocIdDTO.cs new file mode 100644 index 0000000..3a17309 --- /dev/null +++ b/src/Somnia.GuestLog/Models/DTOs/GuestDocIdDTO.cs @@ -0,0 +1,28 @@ +namespace Somnia.GuestLog.Models.DTOs; + +public class GuestDocIdDTO +{ + public required string DocumentType { get; set; } + public required string DocumentNum { get; set; } + + + public static string EncodeAsString(GuestDocIdDTO docId) + { + return $"{docId.DocumentType}-{docId.DocumentNum}"; + } + + public static GuestDocIdDTO? DecodeFromString(string str) + { + var parts = str.Split("-"); + if (parts.Length < 2) + { + return null; + } + + return new() + { + DocumentType = parts[0], + DocumentNum = parts[1] + }; + } +} diff --git a/src/Somnia.GuestLog/Program.cs b/src/Somnia.GuestLog/Program.cs index 3146a04..2e54ad2 100644 --- a/src/Somnia.GuestLog/Program.cs +++ b/src/Somnia.GuestLog/Program.cs @@ -28,6 +28,7 @@ builder.Services.AddRazorComponents() // Add Validation Services builder.Services.AddSingleton, GuestDocumentDTOValidator>(); +builder.Services.AddSingleton, GuestDocIdDTOValidator>(); // Add Services builder.Services.AddSingleton(); diff --git a/src/Somnia.GuestLog/Resources/LanguageLocale.resx b/src/Somnia.GuestLog/Resources/LanguageLocale.resx index 7a036de..bff279b 100644 --- a/src/Somnia.GuestLog/Resources/LanguageLocale.resx +++ b/src/Somnia.GuestLog/Resources/LanguageLocale.resx @@ -149,11 +149,18 @@ Deleting can not be undone! Delete Create + Edit Return Close + Search + Save + Cancel New Guest + This Guest Document Already exists!! + Go to existing document + Document Details Guest Details Contact Details diff --git a/src/Somnia.GuestLog/Services/StaticDataListService.cs b/src/Somnia.GuestLog/Services/StaticDataListService.cs index 7297b51..9b8f4e3 100644 --- a/src/Somnia.GuestLog/Services/StaticDataListService.cs +++ b/src/Somnia.GuestLog/Services/StaticDataListService.cs @@ -51,9 +51,9 @@ public class StaticDataListService( #region DataList Dicts private static readonly HashSet _documentTypeSet = [ - "DNI", - "NIE", - "PASSPORT", + GuestConsts.DocumentType_DNI, + GuestConsts.DocumentType_NIE, + GuestConsts.DocumentType_PASSPORT, "ID", "OTHER", ]; diff --git a/src/Somnia.GuestLog/Utils/DNIUtils.cs b/src/Somnia.GuestLog/Utils/DNIUtils.cs new file mode 100644 index 0000000..de89c64 --- /dev/null +++ b/src/Somnia.GuestLog/Utils/DNIUtils.cs @@ -0,0 +1,106 @@ +namespace Somnia.GuestLog.Utils; + +public static class DNIUtils +{ + #region Validators + + public static bool IsValidDNI(string dni) + { + //Bypass validation if letter if 'Ñ' + if (dni.Length > 1 && dni[^1] == 'Ñ') + { + return true; + } + + if (dni.Length != 9 || !RegexUtils.RegexDniFormat().IsMatch(dni)) + { + return false; + // throw new InvalidOperationException($"DNI with invalid format!! Value: '{dni}'"); + } + + char calculatedLetter = CalculateLetterDniFromDigits(dni[..8]); + + return calculatedLetter == dni[8]; + } + + public static bool IsValidNIE(string nie) + { + //Bypass validation if letter if 'Ñ' + if (nie.Length > 1 && nie[^1] == 'Ñ') + { + return true; + } + + if (nie.Length != 9 || !RegexUtils.RegexNieFormat().IsMatch(nie)) + { + return false; + // throw new InvalidOperationException($"NIE with invalid format!! Value: '{nie}'"); + } + + char calculatedLetter = CalculateLetterNieFromDigits(nie[..8]); + + return calculatedLetter == nie[8]; + } + + + #endregion + + #region Letter Calculators + + public static char CalculateLetterDniFromDigits(string dniDigits) + { + if (dniDigits.Length != 8) + { + throw new InvalidOperationException($"Invalid digit length! Received value '{dniDigits}' is not 8 digits long"); + } + + return GetDniLetter(dniDigits.AsSpan()); + } + + public static char CalculateLetterNieFromDigits(string dniDigits) + { + //Convert first letter to number. + int firstLetterAsDigit = NiePrefixToDigit(dniDigits[0]); + + string newDigits = firstLetterAsDigit + dniDigits[1..]; + + return CalculateLetterDniFromDigits(newDigits); + } + + #endregion + + #region Internal Calculations + + //Letters for remainder 0..22 + private const string Letters = "TRWAGMYFPDXBNJZSQVHLCKE"; + + + // NIE prefixes map to digits: + // X -> 0, Y -> 1, Z -> 2 (so they become 0/1/2 + 7 digits for mod computation) + private static int NiePrefixToDigit(char prefix) + => prefix switch + { + 'X' or 'x' => 0, + 'Y' or 'y' => 1, + 'Z' or 'z' => 2, + _ => throw new ArgumentException("Invalid NIE prefix. Expected X, Y, or Z.") + }; + + private static char GetDniLetter(ReadOnlySpan digits) + { + // Fast path: avoid allocating by parsing digits incrementally as a number mod 23. + int mod = 0; + for (int i = 0; i < digits.Length; i++) + { + char c = digits[i]; + // Optional validation: digits only + if ((uint)(c - '0') > 9) throw new ArgumentException("Non-digit character found."); + mod = (mod * 10 + (c - '0')) % 23; + } + + return Letters[mod]; + } + + #endregion + +} diff --git a/src/Somnia.GuestLog/Utils/RegexUtils.cs b/src/Somnia.GuestLog/Utils/RegexUtils.cs new file mode 100644 index 0000000..946c797 --- /dev/null +++ b/src/Somnia.GuestLog/Utils/RegexUtils.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace Somnia.GuestLog.Utils; + +public static partial class RegexUtils +{ + [GeneratedRegex("^[0-9]{8}[A-Z]$")] + public static partial Regex RegexDniFormat(); + + [GeneratedRegex("^[XYZ][0-9]{7}[A-Z]$")] + public static partial Regex RegexNieFormat(); + + [GeneratedRegex("^[A-Z]{3}[0-9]{6}$")] + public static partial Regex RegexGuestDocSuportNumber(); +} diff --git a/src/Somnia.GuestLog/Validators/CustomValidators.cs b/src/Somnia.GuestLog/Validators/CustomValidators.cs new file mode 100644 index 0000000..d6be1a7 --- /dev/null +++ b/src/Somnia.GuestLog/Validators/CustomValidators.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Somnia.GuestLog.Models.DTOs; +using Somnia.GuestLog.Services; + +namespace Somnia.GuestLog.Validators; + +public static class CustomValidators +{ + public static IRuleBuilderOptions> ListMustContainFewerThan(this IRuleBuilder> ruleBuilder, int num) + { + return ruleBuilder.Must(list => list.Count < num).WithMessage("The list contains too many items"); + } + +} \ No newline at end of file diff --git a/src/Somnia.GuestLog/Validators/GuestDocIdDTOValidator.cs b/src/Somnia.GuestLog/Validators/GuestDocIdDTOValidator.cs new file mode 100644 index 0000000..179acb6 --- /dev/null +++ b/src/Somnia.GuestLog/Validators/GuestDocIdDTOValidator.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using Somnia.GuestLog.Models.DTOs; +using Somnia.GuestLog.Services; +using Somnia.GuestLog.Utils; + +namespace Somnia.GuestLog.Validators; + +/// +/// Validator for GuestDocumentDTO which contains multiple rules and can be shared with the back end API +/// +public class GuestDocIdDTOValidator : AbstractValidator +{ + public GuestDocIdDTOValidator(IDataListService dataListService) + { + RuleFor(x => x.DocumentType) + .NotEmpty() + .Must(val => val != null && dataListService.IsValueInList(GuestConsts.DataList_DocumentTypes, val)); + + RuleFor(x => x.DocumentNum) + .NotEmpty() + .Length(6, 32); + + RuleFor(x => x.DocumentNum) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .Must(num => DNIUtils.IsValidDNI(num)) + .WithMessage("Invalid DNI") + .When(x => x.DocumentType == GuestConsts.DocumentType_DNI); + + RuleFor(x => x.DocumentNum) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .Must(num => DNIUtils.IsValidNIE(num)) + .WithMessage("Invalid NIE") + .When(x => x.DocumentType == GuestConsts.DocumentType_NIE); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((GuestDocIdDTO)model, x => + x.IncludeProperties(propertyName))); + return result.IsValid ? [] : result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/src/Somnia.GuestLog/Validators/GuestDocumentDTOValidator.cs b/src/Somnia.GuestLog/Validators/GuestDocumentDTOValidator.cs index 7737230..767af16 100644 --- a/src/Somnia.GuestLog/Validators/GuestDocumentDTOValidator.cs +++ b/src/Somnia.GuestLog/Validators/GuestDocumentDTOValidator.cs @@ -1,6 +1,7 @@ using FluentValidation; using Somnia.GuestLog.Models.DTOs; using Somnia.GuestLog.Services; +using Somnia.GuestLog.Utils; namespace Somnia.GuestLog.Validators; @@ -12,6 +13,7 @@ public class GuestDocumentDTOValidator : AbstractValidator public GuestDocumentDTOValidator(IDataListService dataListService) { RuleFor(x => x.DocumentType) + .Cascade(CascadeMode.Stop) .NotEmpty() .Must(val => val != null && dataListService.IsValueInList(GuestConsts.DataList_DocumentTypes, val)); @@ -23,6 +25,12 @@ public class GuestDocumentDTOValidator : AbstractValidator .NotEmpty() .LessThanOrEqualTo(DateTime.Today); + RuleFor(x => x.SupportNum) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .Matches(RegexUtils.RegexGuestDocSuportNumber()).WithMessage("Invalid Format. Expected 'AAA000000'.") + .When(x => x.Nationality == GuestConsts.CountryCode_ESP); + RuleFor(x => x.Name) .NotEmpty() .Length(1, 100); @@ -36,6 +44,7 @@ public class GuestDocumentDTOValidator : AbstractValidator .LessThanOrEqualTo(DateTime.Today); RuleFor(x => x.Nationality) + .Cascade(CascadeMode.Stop) .NotEmpty() .Must(val => val != null && dataListService.IsValueInList(GuestConsts.DataList_CountryCodes, val)); @@ -53,12 +62,6 @@ public class GuestDocumentDTOValidator : AbstractValidator .NotEmpty() .Must(val => val != null && dataListService.IsValueInList(GuestConsts.DataList_CountryCodes, val)); - // RuleFor(x => x.Email) - // .Cascade(CascadeMode.Stop) - // .NotEmpty() - // .EmailAddress() - // .MustAsync(async (value, _) => await IsUniqueAsync(value)); - // RuleFor(x => x.CCNumber) // .NotEmpty() // .Length(1, 100)