Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend #1
@@ -1,4 +1,12 @@
|
|||||||
node_modules
|
|
||||||
Dockerfile
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
geradoresfe
|
||||||
|
UnitTest
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -361,5 +361,4 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
/geradoresfe/package-lock.json
|
|
||||||
/geradoresfe/public
|
/geradoresfe/public
|
||||||
|
|||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
|||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
"program": "${workspaceFolder}/GeradoresWS/bin/Debug/net7.0/GeradoresWS.dll",
|
"program": "${workspaceFolder}/GeradoresWS/bin/Debug/net10.0/GeradoresWS.dll",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"stopAtEntry": false,
|
"stopAtEntry": false,
|
||||||
|
|||||||
33
Dockerfile
33
Dockerfile
@@ -1,31 +1,22 @@
|
|||||||
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
# API (GeradoresWS). Contexto de build: raiz do repositório.
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 8080
|
|
||||||
EXPOSE 443
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
|
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
|
||||||
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
|
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
|
||||||
|
|
||||||
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
|
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
|
||||||
COPY . .
|
|
||||||
|
|
||||||
WORKDIR "/src/GeradoresWS"
|
COPY GeradoresService/ GeradoresService/
|
||||||
RUN dotnet build "GeradoresWS.csproj" -c Release -o /app/build
|
COPY GeradoresWS/ GeradoresWS/
|
||||||
|
RUN dotnet publish "GeradoresWS/GeradoresWS.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
|
||||||
|
|
||||||
FROM build AS publish
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
RUN dotnet publish "GeradoresWS.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
|
||||||
|
|
||||||
FROM base AS final
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
# Set environment variables
|
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
EXPOSE 8080
|
||||||
|
USER $APP_UID
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]
|
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace GeradoresService
|
|
||||||
{
|
|
||||||
public static class CartaoCidadao
|
|
||||||
{
|
|
||||||
// Função estática para gerar o número de documento
|
|
||||||
public static string Generate()
|
|
||||||
{
|
|
||||||
Random rand = new Random();
|
|
||||||
|
|
||||||
// Gerar o Número de Identificação Civil (8 dígitos)
|
|
||||||
string numeroIdentificacaoCivil = rand.Next(0, 100000000).ToString("D8");
|
|
||||||
|
|
||||||
// Gerar o Check Digit C
|
|
||||||
int checkDigitC = CalcularCheckDigit(numeroIdentificacaoCivil);
|
|
||||||
|
|
||||||
// Gerar a Versão (duas letras ou números aleatórios entre A-Z ou 0-9)
|
|
||||||
string versao = GerarVersao();
|
|
||||||
|
|
||||||
// Gerar o Check Digit T (Número aleatório entre 0-9)
|
|
||||||
int checkDigitT = rand.Next(0, 10); // T: Número aleatório por enquanto, conforme exemplo.
|
|
||||||
|
|
||||||
// Montar o número completo do documento com o formato desejado
|
|
||||||
string numeroDocumento = $"{numeroIdentificacaoCivil} {checkDigitC} {versao}{checkDigitT}";
|
|
||||||
|
|
||||||
return numeroDocumento;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Função para calcular o Check Digit C com base no Número de Identificação Civil
|
|
||||||
private static int CalcularCheckDigit(string numeroIdentificacaoCivil)
|
|
||||||
{
|
|
||||||
int soma = 0;
|
|
||||||
bool segundoElemento = false;
|
|
||||||
|
|
||||||
// Percorrer o número da direita para a esquerda
|
|
||||||
for (int i = numeroIdentificacaoCivil.Length - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
int valor = numeroIdentificacaoCivil[i] - '0'; // Converte o char para inteiro
|
|
||||||
|
|
||||||
// Multiplicar cada segundo número por 2
|
|
||||||
if (segundoElemento)
|
|
||||||
{
|
|
||||||
valor *= 2;
|
|
||||||
if (valor >= 10) valor -= 9; // Subtrair 9 se o valor for maior ou igual a 10
|
|
||||||
}
|
|
||||||
|
|
||||||
soma += valor;
|
|
||||||
segundoElemento = !segundoElemento;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retornar o check digit (resto da soma por 10)
|
|
||||||
return soma % 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Função para gerar a versão (letras ou números)
|
|
||||||
private static string GerarVersao()
|
|
||||||
{
|
|
||||||
Random rand = new Random();
|
|
||||||
string versao = "";
|
|
||||||
|
|
||||||
for (int i = 0; i < 2; i++) // Gerar duas letras/números
|
|
||||||
{
|
|
||||||
int numeroAleatorio = rand.Next(0, 36); // 26 letras + 10 números
|
|
||||||
|
|
||||||
if (numeroAleatorio < 26)
|
|
||||||
{
|
|
||||||
// Gerar uma letra de A a Z
|
|
||||||
versao += (char)('A' + numeroAleatorio);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Gerar um número de 0 a 9
|
|
||||||
versao += (char)('0' + (numeroAleatorio - 26));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return versao;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
GeradoresService/Core/Checksums.cs
Normal file
71
GeradoresService/Core/Checksums.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace GeradoresService.Core;
|
||||||
|
|
||||||
|
public static class Checksums
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dígito de controlo módulo 11 com pesos decrescentes (n+1 .. 2), usado no NIF e no NIC do CC.
|
||||||
|
/// Resto 0 ou 1 dá dígito 0.
|
||||||
|
/// </summary>
|
||||||
|
public static int Mod11(ReadOnlySpan<char> digits)
|
||||||
|
{
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < digits.Length; i++)
|
||||||
|
sum += (digits[i] - '0') * (digits.Length + 1 - i);
|
||||||
|
var remainder = sum % 11;
|
||||||
|
return remainder < 2 ? 0 : 11 - remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soma Luhn a partir da direita, duplicando posições alternadas.
|
||||||
|
/// Aceita letras (A=10 .. Z=35), como no número de documento do Cartão de Cidadão.
|
||||||
|
/// </summary>
|
||||||
|
public static int LuhnSum(ReadOnlySpan<char> value)
|
||||||
|
{
|
||||||
|
var sum = 0;
|
||||||
|
var doubleIt = false;
|
||||||
|
for (var i = value.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var v = CharValue(value[i]);
|
||||||
|
if (doubleIt)
|
||||||
|
{
|
||||||
|
v *= 2;
|
||||||
|
if (v > 9) v -= 9;
|
||||||
|
}
|
||||||
|
sum += v;
|
||||||
|
doubleIt = !doubleIt;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool LuhnValid(ReadOnlySpan<char> value) => LuhnSum(value) % 10 == 0;
|
||||||
|
|
||||||
|
/// <summary>Calcula o dígito Luhn a acrescentar a <paramref name="payload"/>.</summary>
|
||||||
|
public static int LuhnCheckDigit(string payload)
|
||||||
|
{
|
||||||
|
var sum = LuhnSum(payload + "0");
|
||||||
|
return (10 - sum % 10) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ISO 7064 MOD 97-10 sobre uma string de dígitos.</summary>
|
||||||
|
public static int Mod97(string digits) => (int)(BigInteger.Parse(digits) % 97);
|
||||||
|
|
||||||
|
/// <summary>Converte letras em números (A=10 .. Z=35) para cálculo de IBAN.</summary>
|
||||||
|
public static string LettersToDigits(string value) =>
|
||||||
|
string.Concat(value.Select(c => char.IsLetter(c) ? (char.ToUpperInvariant(c) - 'A' + 10).ToString() : c.ToString()));
|
||||||
|
|
||||||
|
private static int CharValue(char c) => c switch
|
||||||
|
{
|
||||||
|
>= '0' and <= '9' => c - '0',
|
||||||
|
>= 'A' and <= 'Z' => c - 'A' + 10,
|
||||||
|
>= 'a' and <= 'z' => c - 'a' + 10,
|
||||||
|
_ => throw new ArgumentException($"Carácter inválido '{c}'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Remove espaços, hífens, pontos e barras.</summary>
|
||||||
|
public static string Clean(string value) =>
|
||||||
|
new(value.Where(c => !char.IsWhiteSpace(c) && c is not '-' and not '.' and not '/').ToArray());
|
||||||
|
|
||||||
|
public static bool AllDigits(string value) => value.Length > 0 && value.All(char.IsAsciiDigit);
|
||||||
|
}
|
||||||
18
GeradoresService/Core/GeneratorBase.cs
Normal file
18
GeradoresService/Core/GeneratorBase.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace GeradoresService.Core;
|
||||||
|
|
||||||
|
public abstract class GeneratorBase : IGenerator
|
||||||
|
{
|
||||||
|
public abstract string Id { get; }
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public abstract string Category { get; }
|
||||||
|
public abstract string Description { get; }
|
||||||
|
public virtual IReadOnlyList<GeneratorOption> Options => [];
|
||||||
|
public virtual bool HasChecksum => true;
|
||||||
|
public virtual bool CanValidate => true;
|
||||||
|
|
||||||
|
public abstract GeneratedItem Generate(GeneratorContext context);
|
||||||
|
public abstract ValidationResult Validate(string value);
|
||||||
|
|
||||||
|
protected static GeneratorOption Option(string key, string label, string defaultValue, params (string Value, string Label)[] choices) =>
|
||||||
|
new(key, label, choices.Select(c => new OptionChoice(c.Value, c.Label)).ToArray(), defaultValue);
|
||||||
|
}
|
||||||
39
GeradoresService/Core/IGenerator.cs
Normal file
39
GeradoresService/Core/IGenerator.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
namespace GeradoresService.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Um gerador/validador de um tipo de identificador.
|
||||||
|
/// Os metadados (Id, Name, Options, ...) são expostos pela API para o frontend construir a UI.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGenerator
|
||||||
|
{
|
||||||
|
/// <summary>Identificador estável usado nos URLs (ex.: "nif").</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
string Category { get; }
|
||||||
|
|
||||||
|
string Description { get; }
|
||||||
|
|
||||||
|
IReadOnlyList<GeneratorOption> Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se a validação verifica um dígito de controlo (true) ou apenas o formato (false).
|
||||||
|
/// </summary>
|
||||||
|
bool HasChecksum { get; }
|
||||||
|
|
||||||
|
/// <summary>False para geradores compostos que não têm validação (ex.: pessoa fictícia).</summary>
|
||||||
|
bool CanValidate { get; }
|
||||||
|
|
||||||
|
GeneratedItem Generate(GeneratorContext context);
|
||||||
|
|
||||||
|
ValidationResult Validate(string value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Categories
|
||||||
|
{
|
||||||
|
public const string Identificacao = "Identificação";
|
||||||
|
public const string Financeiro = "Financeiro";
|
||||||
|
public const string Contactos = "Contactos e Morada";
|
||||||
|
public const string Outros = "Outros";
|
||||||
|
}
|
||||||
72
GeradoresService/Core/Models.cs
Normal file
72
GeradoresService/Core/Models.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
namespace GeradoresService.Core;
|
||||||
|
|
||||||
|
public sealed record GeneratorOption(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
IReadOnlyList<OptionChoice> Choices,
|
||||||
|
string DefaultValue);
|
||||||
|
|
||||||
|
public sealed record OptionChoice(string Value, string Label);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Valor gerado. <see cref="Fields"/> é usado por geradores compostos (ex.: pessoa fictícia).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GeneratedItem(string Value, IReadOnlyDictionary<string, string>? Fields = null);
|
||||||
|
|
||||||
|
/// <param name="IsValid">O valor é válido.</param>
|
||||||
|
/// <param name="FormatMatch">O valor tem o formato deste tipo (mesmo que o dígito de controlo falhe).</param>
|
||||||
|
/// <param name="Message">Resumo legível.</param>
|
||||||
|
/// <param name="Normalized">Valor normalizado/formatado, quando válido.</param>
|
||||||
|
/// <param name="Details">Passos do cálculo / informação extra.</param>
|
||||||
|
public sealed record ValidationResult(
|
||||||
|
bool IsValid,
|
||||||
|
bool FormatMatch,
|
||||||
|
string Message,
|
||||||
|
string? Normalized = null,
|
||||||
|
IReadOnlyList<string>? Details = null)
|
||||||
|
{
|
||||||
|
public static ValidationResult Valid(string normalized, string message, IReadOnlyList<string>? details = null) =>
|
||||||
|
new(true, true, message, normalized, details);
|
||||||
|
|
||||||
|
public static ValidationResult WrongFormat(string message) =>
|
||||||
|
new(false, false, message);
|
||||||
|
|
||||||
|
public static ValidationResult BadChecksum(string message, IReadOnlyList<string>? details = null) =>
|
||||||
|
new(false, true, message, null, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contexto de geração: fonte de aleatoriedade e opções escolhidas pelo utilizador.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeneratorContext(Random random, IReadOnlyDictionary<string, string?> options)
|
||||||
|
{
|
||||||
|
public Random Random { get; } = random;
|
||||||
|
|
||||||
|
public static GeneratorContext Default(Random? random = null) =>
|
||||||
|
new(random ?? Random.Shared, new Dictionary<string, string?>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Devolve o valor da opção ou o default. O valor tem de ser uma das escolhas válidas.
|
||||||
|
/// </summary>
|
||||||
|
public string Option(GeneratorOption option)
|
||||||
|
{
|
||||||
|
if (options.TryGetValue(option.Key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
var match = option.Choices.FirstOrDefault(c => string.Equals(c.Value, value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is null)
|
||||||
|
throw new ArgumentException($"Valor '{value}' inválido para a opção '{option.Key}'.");
|
||||||
|
return match.Value;
|
||||||
|
}
|
||||||
|
return option.DefaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Digits(int length)
|
||||||
|
{
|
||||||
|
Span<char> buffer = stackalloc char[length];
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
buffer[i] = (char)('0' + Random.Next(10));
|
||||||
|
return new string(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Pick<T>(IReadOnlyList<T> items) => items[Random.Next(items.Count)];
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace GeradoresService.DAL;
|
|
||||||
|
|
||||||
public partial class Geradore
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
public int? Tipo { get; set; }
|
|
||||||
|
|
||||||
public string? Valor { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GeradoresService.DAL;
|
|
||||||
|
|
||||||
public partial class GeradoresContext : DbContext
|
|
||||||
{
|
|
||||||
public GeradoresContext()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public GeradoresContext(DbContextOptions<GeradoresContext> options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual DbSet<Geradore> Geradores { get; set; }
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.Entity<Geradore>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(e => e.Id).HasName("PK__Geradore__3214EC2710D5AD88");
|
|
||||||
|
|
||||||
entity.Property(e => e.Id).HasColumnName("ID");
|
|
||||||
entity.Property(e => e.Valor).HasMaxLength(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
OnModelCreatingPartial(modelBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
|
||||||
}
|
|
||||||
66
GeradoresService/Data/PtData.cs
Normal file
66
GeradoresService/Data/PtData.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
namespace GeradoresService.Data;
|
||||||
|
|
||||||
|
/// <summary>Dados fictícios portugueses usados pelos geradores de pessoas, moradas e emails.</summary>
|
||||||
|
public static class PtData
|
||||||
|
{
|
||||||
|
public static readonly string[] MaleNames =
|
||||||
|
[
|
||||||
|
"João", "Francisco", "Santiago", "Afonso", "Tomás", "Duarte", "Rodrigo", "Martim", "Lourenço", "Gabriel",
|
||||||
|
"Miguel", "Pedro", "Tiago", "Diogo", "Rafael", "Gonçalo", "Guilherme", "Salvador", "Vicente", "Simão",
|
||||||
|
"António", "José", "Manuel", "Rui", "Paulo", "Ricardo", "Nuno", "Bruno", "André", "Luís",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] FemaleNames =
|
||||||
|
[
|
||||||
|
"Maria", "Leonor", "Matilde", "Beatriz", "Carolina", "Mariana", "Ana", "Inês", "Sofia", "Margarida",
|
||||||
|
"Alice", "Benedita", "Francisca", "Constança", "Laura", "Clara", "Lara", "Joana", "Rita", "Catarina",
|
||||||
|
"Marta", "Sara", "Daniela", "Teresa", "Helena", "Isabel", "Patrícia", "Filipa", "Raquel", "Madalena",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] Surnames =
|
||||||
|
[
|
||||||
|
"Silva", "Santos", "Ferreira", "Pereira", "Oliveira", "Costa", "Rodrigues", "Martins", "Jesus", "Sousa",
|
||||||
|
"Fernandes", "Gonçalves", "Gomes", "Lopes", "Marques", "Alves", "Almeida", "Ribeiro", "Pinto", "Carvalho",
|
||||||
|
"Teixeira", "Moreira", "Correia", "Mendes", "Nunes", "Soares", "Vieira", "Monteiro", "Cardoso", "Rocha",
|
||||||
|
"Raposo", "Neves", "Coelho", "Cruz", "Cunha", "Pires", "Ramos", "Reis", "Simões", "Antunes",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] StreetTypes = ["Rua", "Avenida", "Travessa", "Largo", "Praceta", "Alameda", "Calçada"];
|
||||||
|
|
||||||
|
public static readonly string[] StreetNames =
|
||||||
|
[
|
||||||
|
"da Liberdade", "de Camões", "25 de Abril", "da República", "do Comércio", "das Flores", "dos Combatentes",
|
||||||
|
"Dom Afonso Henriques", "Almirante Reis", "da Boavista", "de Santa Catarina", "do Carmo", "da Estação",
|
||||||
|
"Gago Coutinho", "Infante D. Henrique", "Marquês de Pombal", "Miguel Bombarda", "da Igreja", "do Mercado",
|
||||||
|
"Bartolomeu Dias", "Vasco da Gama", "Fernando Pessoa", "Sá da Bandeira", "Cidade de Lyon", "dos Bombeiros",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Código postal (4 dígitos) e localidade de cada capital de distrito/região.</summary>
|
||||||
|
public static readonly (string Cp4, string Locality, string District)[] Localities =
|
||||||
|
[
|
||||||
|
("1000", "Lisboa", "Lisboa"),
|
||||||
|
("4000", "Porto", "Porto"),
|
||||||
|
("3000", "Coimbra", "Coimbra"),
|
||||||
|
("4700", "Braga", "Braga"),
|
||||||
|
("8000", "Faro", "Faro"),
|
||||||
|
("2400", "Leiria", "Leiria"),
|
||||||
|
("3800", "Aveiro", "Aveiro"),
|
||||||
|
("7000", "Évora", "Évora"),
|
||||||
|
("2900", "Setúbal", "Setúbal"),
|
||||||
|
("2000", "Santarém", "Santarém"),
|
||||||
|
("3500", "Viseu", "Viseu"),
|
||||||
|
("5000", "Vila Real", "Vila Real"),
|
||||||
|
("4900", "Viana do Castelo", "Viana do Castelo"),
|
||||||
|
("5300", "Bragança", "Bragança"),
|
||||||
|
("6300", "Guarda", "Guarda"),
|
||||||
|
("6000", "Castelo Branco", "Castelo Branco"),
|
||||||
|
("7300", "Portalegre", "Portalegre"),
|
||||||
|
("7800", "Beja", "Beja"),
|
||||||
|
("9000", "Funchal", "Madeira"),
|
||||||
|
("9500", "Ponta Delgada", "Açores"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SafeEmailDomains = ["example.com", "example.org", "example.net"];
|
||||||
|
|
||||||
|
public static readonly string[] RealisticEmailDomains = ["gmail.com", "sapo.pt", "outlook.pt", "hotmail.com", "yahoo.com", "iol.pt"];
|
||||||
|
}
|
||||||
85
GeradoresService/GeneratorRegistry.cs
Normal file
85
GeradoresService/GeneratorRegistry.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
using GeradoresService.Generators.Contactos;
|
||||||
|
using GeradoresService.Generators.Financeiro;
|
||||||
|
using GeradoresService.Generators.Identificacao;
|
||||||
|
using GeradoresService.Generators.Outros;
|
||||||
|
|
||||||
|
namespace GeradoresService;
|
||||||
|
|
||||||
|
public sealed record DetectionMatch(IGenerator Generator, ValidationResult Result);
|
||||||
|
|
||||||
|
public sealed class GeneratorRegistry
|
||||||
|
{
|
||||||
|
public const int MaxBatch = 1000;
|
||||||
|
|
||||||
|
public IReadOnlyList<IGenerator> All { get; }
|
||||||
|
|
||||||
|
public GeneratorRegistry() : this(
|
||||||
|
[
|
||||||
|
new NifGenerator(),
|
||||||
|
new NipcGenerator(),
|
||||||
|
new NissGenerator(),
|
||||||
|
new CartaoCidadaoGenerator(),
|
||||||
|
new PassaporteGenerator(),
|
||||||
|
new IbanGenerator(),
|
||||||
|
new NibGenerator(),
|
||||||
|
new CartaoCreditoGenerator(),
|
||||||
|
new ReferenciaMultibancoGenerator(),
|
||||||
|
new PessoaGenerator(),
|
||||||
|
new TelefoneGenerator(),
|
||||||
|
new CodigoPostalGenerator(),
|
||||||
|
new EmailGenerator(),
|
||||||
|
new MatriculaGenerator(),
|
||||||
|
new EanGenerator(),
|
||||||
|
new UuidGenerator(),
|
||||||
|
])
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public GeneratorRegistry(IReadOnlyList<IGenerator> generators) => All = generators;
|
||||||
|
|
||||||
|
public IGenerator? Find(string id) =>
|
||||||
|
All.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>Gera <paramref name="count"/> valores. Um <paramref name="seed"/> torna o resultado reprodutível.</summary>
|
||||||
|
public IReadOnlyList<GeneratedItem> Generate(IGenerator generator, int count, IReadOnlyDictionary<string, string?> options, int? seed = null)
|
||||||
|
{
|
||||||
|
if (count is < 1 or > MaxBatch)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(count), $"A quantidade deve estar entre 1 e {MaxBatch}.");
|
||||||
|
|
||||||
|
var context = new GeneratorContext(seed is { } s ? new Random(s) : Random.Shared, options);
|
||||||
|
var items = new GeneratedItem[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
items[i] = generator.Generate(context);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validador livre: tenta todos os tipos. Devolve primeiro os válidos, depois os que têm o formato
|
||||||
|
/// certo mas falham o controlo (ex.: "parece um NIF mas o dígito está errado").
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<DetectionMatch> Detect(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return All
|
||||||
|
.Where(g => g.CanValidate)
|
||||||
|
.Select(g => new DetectionMatch(g, SafeValidate(g, value)))
|
||||||
|
.Where(m => m.Result.FormatMatch)
|
||||||
|
.OrderByDescending(m => m.Result.IsValid)
|
||||||
|
.ThenByDescending(m => m.Generator.HasChecksum)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValidationResult SafeValidate(IGenerator generator, string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return generator.Validate(value);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or ArgumentException or OverflowException)
|
||||||
|
{
|
||||||
|
return ValidationResult.WrongFormat(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
using GeradoresService.Data;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Contactos;
|
||||||
|
|
||||||
|
/// <summary>Código postal português (NNNN-NNN). Apenas formato.</summary>
|
||||||
|
public sealed partial class CodigoPostalGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "codigo-postal";
|
||||||
|
public override string Name => "Código postal";
|
||||||
|
public override string Category => Categories.Contactos;
|
||||||
|
public override string Description => "Código postal português (CP4-CP3) com localidade.";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var (cp, locality, district) = Random(context);
|
||||||
|
return new GeneratedItem(cp, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Código postal"] = cp,
|
||||||
|
["Localidade"] = locality,
|
||||||
|
["Distrito"] = district,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string Cp, string Locality, string District) Random(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var l = context.Pick(PtData.Localities);
|
||||||
|
return ($"{l.Cp4}-{1 + context.Random.Next(999):D3}", l.Locality, l.District);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var m = Pattern().Match(value.Trim());
|
||||||
|
if (!m.Success)
|
||||||
|
return ValidationResult.WrongFormat("Formato esperado: NNNN-NNN (ex.: 1000-001).");
|
||||||
|
var cp = $"{m.Groups[1].Value}-{m.Groups[2].Value}";
|
||||||
|
return ValidationResult.Valid(cp, "Formato de código postal válido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^([1-9]\d{3})[-\s]?(\d{3})$")]
|
||||||
|
private static partial Regex Pattern();
|
||||||
|
}
|
||||||
56
GeradoresService/Generators/Contactos/EmailGenerator.cs
Normal file
56
GeradoresService/Generators/Contactos/EmailGenerator.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Text;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
using GeradoresService.Data;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Contactos;
|
||||||
|
|
||||||
|
public sealed class EmailGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "email";
|
||||||
|
public override string Name => "Email";
|
||||||
|
public override string Category => Categories.Contactos;
|
||||||
|
public override string Description => "Endereço de email fictício. Por omissão usa domínios reservados para testes (example.com).";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("dominio", "Domínio", "teste",
|
||||||
|
("teste", "Reservado para testes (example.com)"),
|
||||||
|
("realista", "Realista (gmail.com, sapo.pt…)")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var first = context.Random.Next(2) == 0 ? context.Pick(PtData.MaleNames) : context.Pick(PtData.FemaleNames);
|
||||||
|
return new GeneratedItem(Build(first, context.Pick(PtData.Surnames), context, context.Option(Options[0]) == "realista"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Build(string firstName, string surname, GeneratorContext context, bool realistic = false)
|
||||||
|
{
|
||||||
|
var domains = realistic ? PtData.RealisticEmailDomains : PtData.SafeEmailDomains;
|
||||||
|
var separator = context.Pick([".", "_", ""]);
|
||||||
|
var suffix = context.Random.Next(3) == 0 ? context.Random.Next(1, 100).ToString() : "";
|
||||||
|
return $"{Slug(firstName)}{separator}{Slug(surname)}{suffix}@{context.Pick(domains)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Slug(string s)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var c in s.Normalize(NormalizationForm.FormD))
|
||||||
|
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark && char.IsLetterOrDigit(c))
|
||||||
|
sb.Append(char.ToLowerInvariant(c));
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var v = value.Trim();
|
||||||
|
var at = v.LastIndexOf('@');
|
||||||
|
if (at <= 0 || at == v.Length - 1 || !v[(at + 1)..].Contains('.') || v.Any(char.IsWhiteSpace)
|
||||||
|
|| !MailAddress.TryCreate(v, out var address) || address.Address != v)
|
||||||
|
return ValidationResult.WrongFormat("Não é um endereço de email válido.");
|
||||||
|
return ValidationResult.Valid(v.ToLowerInvariant(), "Formato de email válido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
69
GeradoresService/Generators/Contactos/PessoaGenerator.cs
Normal file
69
GeradoresService/Generators/Contactos/PessoaGenerator.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
using GeradoresService.Data;
|
||||||
|
using GeradoresService.Generators.Financeiro;
|
||||||
|
using GeradoresService.Generators.Identificacao;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Contactos;
|
||||||
|
|
||||||
|
/// <summary>Pessoa fictícia completa, com todos os identificadores válidos.</summary>
|
||||||
|
public sealed class PessoaGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "pessoa";
|
||||||
|
public override string Name => "Pessoa fictícia";
|
||||||
|
public override string Category => Categories.Contactos;
|
||||||
|
public override string Description => "Nome, data de nascimento, NIF, NISS, CC, contactos, morada e IBAN — tudo consistente e válido.";
|
||||||
|
public override bool CanValidate => false;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("genero", "Género", "qualquer", ("qualquer", "Qualquer"), ("f", "Feminino"), ("m", "Masculino")),
|
||||||
|
Option("idade", "Idade", "adulto", ("adulto", "Adulto (18–80)"), ("menor", "Menor (0–17)"), ("qualquer", "Qualquer (0–90)")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value) =>
|
||||||
|
ValidationResult.WrongFormat("Este gerador não tem validação.");
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var genero = context.Option(Options[0]);
|
||||||
|
if (genero == "qualquer")
|
||||||
|
genero = context.Random.Next(2) == 0 ? "f" : "m";
|
||||||
|
|
||||||
|
var names = genero == "f" ? PtData.FemaleNames : PtData.MaleNames;
|
||||||
|
var first = context.Pick(names);
|
||||||
|
var middle = context.Random.Next(3) == 0 ? " " + context.Pick(names.Where(n => n != first).ToArray()) : "";
|
||||||
|
var surname1 = context.Pick(PtData.Surnames);
|
||||||
|
var surname2 = context.Pick(PtData.Surnames.Where(s => s != surname1).ToArray());
|
||||||
|
var fullName = $"{first}{middle} {surname1} {surname2}";
|
||||||
|
|
||||||
|
var (minAge, maxAge) = context.Option(Options[1]) switch
|
||||||
|
{
|
||||||
|
"menor" => (0, 17),
|
||||||
|
"qualquer" => (0, 90),
|
||||||
|
_ => (18, 80),
|
||||||
|
};
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var birth = today.AddYears(-maxAge - 1).AddDays(1 + context.Random.Next((maxAge - minAge + 1) * 365));
|
||||||
|
|
||||||
|
var (cp, locality, _) = CodigoPostalGenerator.Random(context);
|
||||||
|
var street = $"{context.Pick(PtData.StreetTypes)} {context.Pick(PtData.StreetNames)}, {1 + context.Random.Next(300)}";
|
||||||
|
var nibContext = new GeneratorContext(context.Random, new Dictionary<string, string?>());
|
||||||
|
|
||||||
|
var fields = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Nome"] = fullName,
|
||||||
|
["Género"] = genero == "f" ? "Feminino" : "Masculino",
|
||||||
|
["Data de nascimento"] = birth.ToString("yyyy-MM-dd"),
|
||||||
|
["NIF"] = NifGenerator.Build(context.Pick(["1", "2", "3"]), context),
|
||||||
|
["NISS"] = new NissGenerator().Generate(nibContext).Value,
|
||||||
|
["Cartão de Cidadão"] = new CartaoCidadaoGenerator().Generate(nibContext).Value,
|
||||||
|
["Telemóvel"] = new TelefoneGenerator().Generate(nibContext).Value,
|
||||||
|
["Email"] = EmailGenerator.Build(first, surname2, context),
|
||||||
|
["Morada"] = street,
|
||||||
|
["Código postal"] = cp,
|
||||||
|
["Localidade"] = locality,
|
||||||
|
["IBAN"] = new IbanGenerator().Generate(nibContext).Value,
|
||||||
|
};
|
||||||
|
return new GeneratedItem(fullName, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
GeradoresService/Generators/Contactos/TelefoneGenerator.cs
Normal file
67
GeradoresService/Generators/Contactos/TelefoneGenerator.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Contactos;
|
||||||
|
|
||||||
|
/// <summary>Números de telefone portugueses (Plano Nacional de Numeração). Apenas formato.</summary>
|
||||||
|
public sealed class TelefoneGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
private static readonly string[] MobilePrefixes = ["91", "92", "93", "96"];
|
||||||
|
|
||||||
|
public override string Id => "telefone";
|
||||||
|
public override string Name => "Telefone";
|
||||||
|
public override string Category => Categories.Contactos;
|
||||||
|
public override string Description => "Telemóvel ou telefone fixo português.";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("tipo", "Tipo", "movel", ("movel", "Telemóvel"), ("fixo", "Fixo"), ("qualquer", "Qualquer")),
|
||||||
|
Option("formato", "Formato", "nacional",
|
||||||
|
("nacional", "912 345 678"), ("internacional", "+351 912 345 678"), ("compacto", "912345678")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var tipo = context.Option(Options[0]);
|
||||||
|
if (tipo == "qualquer")
|
||||||
|
tipo = context.Random.Next(2) == 0 ? "movel" : "fixo";
|
||||||
|
|
||||||
|
var number = tipo == "movel"
|
||||||
|
? context.Pick(MobilePrefixes) + context.Digits(7)
|
||||||
|
: "2" + (1 + context.Random.Next(9)) + context.Digits(7);
|
||||||
|
|
||||||
|
return new GeneratedItem(Format(number, context.Option(Options[1])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Format(string n, string formato) => formato switch
|
||||||
|
{
|
||||||
|
"compacto" => n,
|
||||||
|
"internacional" => $"+351 {n[..3]} {n[3..6]} {n[6..]}",
|
||||||
|
_ => $"{n[..3]} {n[3..6]} {n[6..]}",
|
||||||
|
};
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var n = Checksums.Clean(value).Replace("(", "").Replace(")", "");
|
||||||
|
if (n.StartsWith("+351")) n = n[4..];
|
||||||
|
else if (n.StartsWith("00351")) n = n[5..];
|
||||||
|
|
||||||
|
if (n.Length != 9 || !Checksums.AllDigits(n))
|
||||||
|
return ValidationResult.WrongFormat("Um número português tem 9 dígitos (opcionalmente com +351).");
|
||||||
|
|
||||||
|
var tipo = n switch
|
||||||
|
{
|
||||||
|
['9', '1' or '2' or '3' or '6', ..] => "telemóvel",
|
||||||
|
['2', ..] => "fixo",
|
||||||
|
['3', '0', ..] => "VoIP nómada",
|
||||||
|
['8', '0', '0', ..] => "número verde (gratuito)",
|
||||||
|
['8', '0', '8', ..] => "número azul (custo partilhado)",
|
||||||
|
['7', '0', '7', ..] or ['7', '6', '0', ..] => "número de valor acrescentado",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return tipo is null
|
||||||
|
? ValidationResult.WrongFormat("O prefixo não corresponde a um número português conhecido.")
|
||||||
|
: ValidationResult.Valid(Format(n, "internacional"), $"Número português válido ({tipo}).", [$"Tipo: {tipo}"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Financeiro;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Números de cartão de pagamento válidos pelo algoritmo de Luhn (apenas para testes).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CartaoCreditoGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
private sealed record Brand(string Key, string Name, int Length, Func<Random, string> Prefix, Func<string, bool> Matches);
|
||||||
|
|
||||||
|
private static readonly Brand[] Brands =
|
||||||
|
[
|
||||||
|
new("visa", "Visa", 16, _ => "4", n => n.StartsWith('4') && n.Length is 13 or 16 or 19),
|
||||||
|
new("mastercard", "Mastercard", 16,
|
||||||
|
r => r.Next(2) == 0 ? (51 + r.Next(5)).ToString() : (2221 + r.Next(500)).ToString(),
|
||||||
|
n => n.Length == 16 && (InRange(n, 2, 51, 55) || InRange(n, 4, 2221, 2720))),
|
||||||
|
new("amex", "American Express", 15, r => r.Next(2) == 0 ? "34" : "37",
|
||||||
|
n => n.Length == 15 && (n.StartsWith("34") || n.StartsWith("37"))),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override string Id => "cartao-credito";
|
||||||
|
public override string Name => "Cartão de crédito";
|
||||||
|
public override string Category => Categories.Financeiro;
|
||||||
|
public override string Description => "Número de cartão válido pelo algoritmo de Luhn, com validade e CVV. Apenas para testes.";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("rede", "Rede", "visa", [.. Brands.Select(b => (b.Key, b.Name)), ("qualquer", "Qualquer")]),
|
||||||
|
Option("formato", "Formato", "espacos", ("espacos", "4111 1111 1111 1111"), ("compacto", "4111111111111111")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var key = context.Option(Options[0]);
|
||||||
|
var brand = key == "qualquer" ? context.Pick(Brands) : Brands.Single(b => b.Key == key);
|
||||||
|
|
||||||
|
var body = brand.Prefix(context.Random);
|
||||||
|
body += context.Digits(brand.Length - 1 - body.Length);
|
||||||
|
var number = body + Checksums.LuhnCheckDigit(body);
|
||||||
|
|
||||||
|
var expiry = DateTime.UtcNow.AddMonths(context.Random.Next(6, 60));
|
||||||
|
var cvv = context.Digits(brand.Key == "amex" ? 4 : 3);
|
||||||
|
var display = context.Option(Options[1]) == "compacto" ? number : Format(number);
|
||||||
|
|
||||||
|
return new GeneratedItem(display, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Número"] = display,
|
||||||
|
["Rede"] = brand.Name,
|
||||||
|
["Validade"] = expiry.ToString("MM/yy"),
|
||||||
|
["CVV"] = cvv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Format(string number) => number.Length == 15
|
||||||
|
? $"{number[..4]} {number[4..10]} {number[10..]}"
|
||||||
|
: string.Join(' ', number.Chunk(4).Select(c => new string(c)));
|
||||||
|
|
||||||
|
private static bool InRange(string n, int digits, int min, int max) =>
|
||||||
|
int.TryParse(n.AsSpan(0, digits), out var p) && p >= min && p <= max;
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var number = Checksums.Clean(value);
|
||||||
|
if (number.Length is < 12 or > 19 || !Checksums.AllDigits(number))
|
||||||
|
return ValidationResult.WrongFormat("Um número de cartão tem entre 12 e 19 dígitos.");
|
||||||
|
|
||||||
|
var brand = Brands.FirstOrDefault(b => b.Matches(number));
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"Rede: {brand?.Name ?? "desconhecida"}",
|
||||||
|
"Controlo: algoritmo de Luhn",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Checksums.LuhnValid(number))
|
||||||
|
{
|
||||||
|
var expected = Checksums.LuhnCheckDigit(number[..^1]);
|
||||||
|
return ValidationResult.BadChecksum($"Falha no algoritmo de Luhn: último dígito devia ser {expected}.", details);
|
||||||
|
}
|
||||||
|
return ValidationResult.Valid(Format(number), $"Número de cartão válido{(brand is null ? "" : $" ({brand.Name})")}.", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
GeradoresService/Generators/Financeiro/IbanGenerator.cs
Normal file
75
GeradoresService/Generators/Financeiro/IbanGenerator.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Financeiro;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IBAN português: PT + 2 dígitos de controlo ISO 7064 (MOD 97-10) + NIB.
|
||||||
|
/// A validação aceita IBAN de qualquer país (verifica comprimento conhecido e MOD 97).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IbanGenerator : NibGenerator
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, int> Lengths = new()
|
||||||
|
{
|
||||||
|
["PT"] = 25, ["ES"] = 24, ["FR"] = 27, ["DE"] = 22, ["IT"] = 27, ["GB"] = 22, ["IE"] = 22,
|
||||||
|
["NL"] = 18, ["BE"] = 16, ["LU"] = 20, ["CH"] = 21, ["AT"] = 20, ["DK"] = 18, ["FI"] = 18,
|
||||||
|
["SE"] = 24, ["NO"] = 15, ["PL"] = 28, ["CZ"] = 24, ["GR"] = 27, ["HR"] = 21, ["RO"] = 24,
|
||||||
|
["MT"] = 31, ["CY"] = 28, ["SK"] = 24, ["SI"] = 19, ["HU"] = 28, ["BG"] = 22, ["EE"] = 20,
|
||||||
|
["LT"] = 20, ["LV"] = 21, ["IS"] = 26, ["LI"] = 21, ["MC"] = 27, ["SM"] = 27, ["BR"] = 29,
|
||||||
|
};
|
||||||
|
|
||||||
|
public override string Id => "iban";
|
||||||
|
public override string Name => "IBAN";
|
||||||
|
public override string Description => "IBAN português (PT50 + NIB). Valida IBAN de qualquer país.";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
.. base.Options,
|
||||||
|
Option("formato", "Formato", "espacos", ("espacos", "PT50 0000 0000 …"), ("compacto", "PT500000…")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var iban = FromBban("PT", BuildNib(context));
|
||||||
|
return new GeneratedItem(context.Option(Options[1]) == "compacto" ? iban : Format(iban));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FromBban(string country, string bban)
|
||||||
|
{
|
||||||
|
var check = 98 - Checksums.Mod97(Checksums.LettersToDigits(bban + country) + "00");
|
||||||
|
return $"{country}{check:D2}{bban}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Format(string iban) =>
|
||||||
|
string.Join(' ', iban.Chunk(4).Select(c => new string(c)));
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var iban = Checksums.Clean(value).ToUpperInvariant();
|
||||||
|
if (iban.Length < 15 || iban.Length > 34
|
||||||
|
|| !char.IsAsciiLetter(iban[0]) || !char.IsAsciiLetter(iban[1])
|
||||||
|
|| !char.IsAsciiDigit(iban[2]) || !char.IsAsciiDigit(iban[3])
|
||||||
|
|| !iban.All(char.IsAsciiLetterOrDigit))
|
||||||
|
return ValidationResult.WrongFormat("Um IBAN tem código de país (2 letras), 2 dígitos de controlo e o BBAN.");
|
||||||
|
|
||||||
|
var country = iban[..2];
|
||||||
|
if (Lengths.TryGetValue(country, out var expectedLength) && iban.Length != expectedLength)
|
||||||
|
return ValidationResult.WrongFormat($"Um IBAN {country} tem {expectedLength} caracteres (tem {iban.Length}).");
|
||||||
|
|
||||||
|
var rearranged = Checksums.LettersToDigits(iban[4..] + iban[..4]);
|
||||||
|
if (Checksums.Mod97(rearranged) != 1)
|
||||||
|
{
|
||||||
|
var expected = FromBban(country, iban[4..])[2..4];
|
||||||
|
return ValidationResult.BadChecksum(
|
||||||
|
$"Dígitos de controlo do IBAN inválidos: esperado {expected}, encontrado {iban[2..4]}.",
|
||||||
|
["Controlo: ISO 7064 MOD 97-10"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country == "PT")
|
||||||
|
{
|
||||||
|
var nibResult = ValidateNib(iban[4..], "IBAN");
|
||||||
|
return nibResult with { Normalized = nibResult.IsValid ? Format(iban) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult.Valid(Format(iban), $"IBAN válido ({country}).", ["Controlo: ISO 7064 MOD 97-10"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
GeradoresService/Generators/Financeiro/NibGenerator.cs
Normal file
75
GeradoresService/Generators/Financeiro/NibGenerator.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Financeiro;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NIB: banco (4) + balcão (4) + conta (11) + controlo (2). Controlo = 98 - (NIB19 × 100 mod 97).
|
||||||
|
/// </summary>
|
||||||
|
public class NibGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<(string Code, string Name)> Banks =
|
||||||
|
[
|
||||||
|
("0007", "Novo Banco"),
|
||||||
|
("0010", "BPI"),
|
||||||
|
("0018", "Santander Totta"),
|
||||||
|
("0023", "Banco ActivoBank"),
|
||||||
|
("0033", "Millennium BCP"),
|
||||||
|
("0035", "Caixa Geral de Depósitos"),
|
||||||
|
("0036", "Banco Montepio"),
|
||||||
|
("0045", "Crédito Agrícola"),
|
||||||
|
("0079", "Banco BIC"),
|
||||||
|
("0193", "Banco CTT"),
|
||||||
|
("0269", "Bankinter"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override string Id => "nib";
|
||||||
|
public override string Name => "NIB";
|
||||||
|
public override string Category => Categories.Financeiro;
|
||||||
|
public override string Description => "Número de Identificação Bancária (21 dígitos).";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("banco", "Banco", "qualquer",
|
||||||
|
[("qualquer", "Qualquer"), .. Banks.Select(b => (b.Code, $"{b.Code} — {b.Name}"))]),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context) => new(BuildNib(context));
|
||||||
|
|
||||||
|
protected string BuildNib(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var bank = context.Option(Options[0]);
|
||||||
|
if (bank == "qualquer")
|
||||||
|
bank = context.Pick(Banks).Code;
|
||||||
|
var body = bank + context.Digits(4) + context.Digits(11);
|
||||||
|
return body + NibCheck(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NibCheck(string first19) =>
|
||||||
|
(98 - Checksums.Mod97(first19 + "00")).ToString("D2");
|
||||||
|
|
||||||
|
public static string? BankName(string nib) =>
|
||||||
|
Banks.FirstOrDefault(b => nib.StartsWith(b.Code)).Name;
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var nib = Checksums.Clean(value);
|
||||||
|
if (nib.Length != 21 || !Checksums.AllDigits(nib))
|
||||||
|
return ValidationResult.WrongFormat("Um NIB tem 21 dígitos.");
|
||||||
|
return ValidateNib(nib, "NIB");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ValidationResult ValidateNib(string nib, string label)
|
||||||
|
{
|
||||||
|
var expected = NibCheck(nib[..19]);
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"Banco: {nib[..4]}{(BankName(nib) is { } n ? $" ({n})" : "")}",
|
||||||
|
$"Balcão: {nib[4..8]}",
|
||||||
|
$"Conta: {nib[8..19]}",
|
||||||
|
$"Dígitos de controlo esperados: {expected} (módulo 97)",
|
||||||
|
};
|
||||||
|
return nib[19..] == expected
|
||||||
|
? ValidationResult.Valid(nib, $"{label} válido.", details)
|
||||||
|
: ValidationResult.BadChecksum($"Dígitos de controlo do NIB inválidos: esperado {expected}, encontrado {nib[19..]}.", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Financeiro;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Referência Multibanco: entidade (5) + referência (9) + montante.
|
||||||
|
/// Os 2 últimos dígitos da referência são controlo: 98 - (Σ dígito × peso mod 97),
|
||||||
|
/// calculado sobre entidade(5) + referência(7) + montante em cêntimos(8).
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ReferenciaMultibancoGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
private static readonly int[] Weights = [51, 73, 17, 89, 38, 62, 45, 53, 15, 50, 5, 49, 34, 81, 76, 27, 90, 9, 30, 3];
|
||||||
|
|
||||||
|
public override string Id => "multibanco";
|
||||||
|
public override string Name => "Referência Multibanco";
|
||||||
|
public override string Category => Categories.Financeiro;
|
||||||
|
public override string Description => "Entidade, referência e montante com dígitos de controlo válidos.";
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var entity = (10000 + context.Random.Next(90000)).ToString();
|
||||||
|
var cents = 100 + context.Random.Next(50000);
|
||||||
|
var reference = context.Digits(7);
|
||||||
|
reference += CheckDigits(entity, reference, cents);
|
||||||
|
|
||||||
|
var amount = (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||||
|
return new GeneratedItem($"{entity} {FormatReference(reference)} {amount}", new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Entidade"] = entity,
|
||||||
|
["Referência"] = FormatReference(reference),
|
||||||
|
["Montante"] = $"{amount} €",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CheckDigits(string entity, string reference7, long cents)
|
||||||
|
{
|
||||||
|
var chk = entity + reference7 + cents.ToString("D8");
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
sum += (chk[i] - '0') * Weights[i];
|
||||||
|
return (98 - sum % 97).ToString("D2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatReference(string r) => $"{r[..3]} {r[3..6]} {r[6..]}";
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var m = Pattern().Match(value.Trim());
|
||||||
|
if (!m.Success)
|
||||||
|
return ValidationResult.WrongFormat("Formato esperado: entidade (5 dígitos), referência (9 dígitos) e montante, ex.: 12345 123 456 789 10.00");
|
||||||
|
|
||||||
|
var entity = m.Groups["ent"].Value;
|
||||||
|
var reference = Checksums.Clean(m.Groups["ref"].Value);
|
||||||
|
var amount = decimal.Parse(m.Groups["val"].Value.Replace(',', '.'), CultureInfo.InvariantCulture);
|
||||||
|
var cents = (long)Math.Round(amount * 100);
|
||||||
|
if (cents > 99_999_999)
|
||||||
|
return ValidationResult.WrongFormat("Montante demasiado elevado.");
|
||||||
|
|
||||||
|
var expected = CheckDigits(entity, reference[..7], cents);
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"Entidade: {entity}",
|
||||||
|
$"Montante: {amount.ToString("0.00", CultureInfo.InvariantCulture)} €",
|
||||||
|
$"Dígitos de controlo esperados: {expected}",
|
||||||
|
};
|
||||||
|
return reference[7..] == expected
|
||||||
|
? ValidationResult.Valid($"{entity} {FormatReference(reference)} {amount.ToString("0.00", CultureInfo.InvariantCulture)}", "Referência Multibanco válida.", details)
|
||||||
|
: ValidationResult.BadChecksum($"Dígitos de controlo inválidos: esperado {expected}, encontrado {reference[7..]}.", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?<ent>\d{5})[\s|;]+(?<ref>\d{3}\s?\d{3}\s?\d{3})[\s|;]+(?<val>\d{1,6}(?:[.,]\d{1,2})?)\s*€?$")]
|
||||||
|
private static partial Regex Pattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Identificacao;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Número de documento do Cartão de Cidadão: NNNNNNNN C VV T
|
||||||
|
/// - NNNNNNNN: número de identificação civil (NIC)
|
||||||
|
/// - C: dígito de controlo do NIC (módulo 11)
|
||||||
|
/// - VV: versão (duas letras/dígitos)
|
||||||
|
/// - T: dígito de controlo do documento completo (Luhn com A=10..Z=35), conforme especificação da AMA.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CartaoCidadaoGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "cc";
|
||||||
|
public override string Name => "Cartão de Cidadão";
|
||||||
|
public override string Category => Categories.Identificacao;
|
||||||
|
public override string Description => "Número de documento do Cartão de Cidadão.";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("formato", "Formato", "espacos", ("espacos", "12345678 9 ZZ1"), ("compacto", "123456789ZZ1")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var nic = context.Digits(8);
|
||||||
|
var c = Checksums.Mod11(nic);
|
||||||
|
// As versões começam em ZZ e vão decrementando.
|
||||||
|
var version = "Z" + (char)('A' + context.Random.Next(26));
|
||||||
|
var payload = $"{nic}{c}{version}";
|
||||||
|
var t = Checksums.LuhnCheckDigit(payload);
|
||||||
|
var compact = payload + t;
|
||||||
|
return new GeneratedItem(context.Option(Options[0]) == "compacto" ? compact : Format(compact));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Format(string compact) => $"{compact[..8]} {compact[8]} {compact[9..]}";
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var cc = Checksums.Clean(value).ToUpperInvariant();
|
||||||
|
if (cc.Length != 12
|
||||||
|
|| !Checksums.AllDigits(cc[..9])
|
||||||
|
|| !cc[9..11].All(char.IsAsciiLetterOrDigit)
|
||||||
|
|| !char.IsAsciiDigit(cc[11]))
|
||||||
|
return ValidationResult.WrongFormat("Formato esperado: 8 dígitos, 1 dígito, 2 letras/dígitos de versão e 1 dígito (ex.: 00000000 0 ZZ4).");
|
||||||
|
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"NIC: {cc[..8]}",
|
||||||
|
$"Versão: {cc[9..11]}",
|
||||||
|
"Controlo: algoritmo Luhn sobre os 12 caracteres (A=10 … Z=35)",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Checksums.LuhnValid(cc))
|
||||||
|
{
|
||||||
|
var expected = Checksums.LuhnCheckDigit(cc[..11]);
|
||||||
|
return ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {cc[11]}.", details);
|
||||||
|
}
|
||||||
|
return ValidationResult.Valid(Format(cc), "Número de documento do CC válido.", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
GeradoresService/Generators/Identificacao/NifGenerator.cs
Normal file
119
GeradoresService/Generators/Identificacao/NifGenerator.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Identificacao;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Número de Identificação Fiscal. 9 dígitos; o último é módulo 11 com pesos 9..2.
|
||||||
|
/// </summary>
|
||||||
|
public class NifGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public sealed record NifPrefix(string Prefix, string Description, bool Colectiva);
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<NifPrefix> Prefixes =
|
||||||
|
[
|
||||||
|
new("1", "Pessoa singular", false),
|
||||||
|
new("2", "Pessoa singular", false),
|
||||||
|
new("3", "Pessoa singular", false),
|
||||||
|
new("45", "Pessoa singular não residente", false),
|
||||||
|
new("5", "Pessoa colectiva", true),
|
||||||
|
new("6", "Administração pública", true),
|
||||||
|
new("70", "Herança indivisa", false),
|
||||||
|
new("71", "Pessoa colectiva não residente", true),
|
||||||
|
new("72", "Fundo de investimento", true),
|
||||||
|
new("74", "Herança indivisa", false),
|
||||||
|
new("75", "Herança indivisa", false),
|
||||||
|
new("77", "Atribuição oficiosa", false),
|
||||||
|
new("78", "Não residente (reembolso de IVA)", false),
|
||||||
|
new("79", "Regime excepcional (Expo 98)", false),
|
||||||
|
new("8", "Empresário em nome individual (extinto)", false),
|
||||||
|
new("90", "Condomínio / sociedade irregular", true),
|
||||||
|
new("91", "Condomínio / sociedade irregular", true),
|
||||||
|
new("98", "Não residente sem estabelecimento estável", true),
|
||||||
|
new("99", "Sociedade civil sem personalidade jurídica", true),
|
||||||
|
];
|
||||||
|
|
||||||
|
private const string AnySingular = "singular";
|
||||||
|
private const string Any = "qualquer";
|
||||||
|
|
||||||
|
public override string Id => "nif";
|
||||||
|
public override string Name => "NIF";
|
||||||
|
public override string Category => Categories.Identificacao;
|
||||||
|
public override string Description => "Número de Identificação Fiscal (contribuinte).";
|
||||||
|
|
||||||
|
protected virtual IEnumerable<NifPrefix> AllowedPrefixes => Prefixes;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("tipo", "Tipo", DefaultChoice,
|
||||||
|
[.. ExtraChoices, .. AllowedPrefixes.Select(p => (p.Prefix, $"{p.Prefix} — {p.Description}"))]),
|
||||||
|
];
|
||||||
|
|
||||||
|
protected virtual string DefaultChoice => AnySingular;
|
||||||
|
|
||||||
|
protected virtual (string, string)[] ExtraChoices =>
|
||||||
|
[
|
||||||
|
(AnySingular, "Pessoa singular (1, 2 ou 3)"),
|
||||||
|
(Any, "Qualquer"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var choice = context.Option(Options[0]);
|
||||||
|
var prefix = choice switch
|
||||||
|
{
|
||||||
|
AnySingular => context.Pick(["1", "2", "3"]),
|
||||||
|
Any => context.Pick(AllowedPrefixes.ToArray()).Prefix,
|
||||||
|
_ => choice,
|
||||||
|
};
|
||||||
|
return new GeneratedItem(Build(prefix, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Build(string prefix, GeneratorContext context)
|
||||||
|
{
|
||||||
|
var body = prefix + context.Digits(8 - prefix.Length);
|
||||||
|
return body + Checksums.Mod11(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var nif = Checksums.Clean(value);
|
||||||
|
if (nif.StartsWith("PT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
nif = nif[2..];
|
||||||
|
|
||||||
|
if (nif.Length != 9 || !Checksums.AllDigits(nif))
|
||||||
|
return ValidationResult.WrongFormat("Um NIF tem 9 dígitos.");
|
||||||
|
|
||||||
|
var prefix = FindPrefix(nif);
|
||||||
|
if (prefix is null || !AllowedPrefixes.Contains(prefix))
|
||||||
|
return ValidationResult.WrongFormat($"Prefixo '{nif[..2]}' não corresponde a um {Name} válido.");
|
||||||
|
|
||||||
|
var expected = Checksums.Mod11(nif.AsSpan(0, 8));
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"Prefixo {prefix.Prefix}: {prefix.Description}",
|
||||||
|
$"Dígito de controlo esperado: {expected} (módulo 11, pesos 9..2)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return expected == nif[8] - '0'
|
||||||
|
? ValidationResult.Valid(nif, $"{Name} válido — {prefix.Description}.", details)
|
||||||
|
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {nif[8]}.", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NifPrefix? FindPrefix(string nif) =>
|
||||||
|
Prefixes.FirstOrDefault(p => p.Prefix.Length == 2 && nif.StartsWith(p.Prefix))
|
||||||
|
?? Prefixes.FirstOrDefault(p => p.Prefix.Length == 1 && nif.StartsWith(p.Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NIPC: NIF de pessoas colectivas. Mesmo algoritmo, prefixos restritos.</summary>
|
||||||
|
public sealed class NipcGenerator : NifGenerator
|
||||||
|
{
|
||||||
|
public override string Id => "nipc";
|
||||||
|
public override string Name => "NIPC";
|
||||||
|
public override string Description => "Número de Identificação de Pessoa Colectiva (empresas e entidades).";
|
||||||
|
|
||||||
|
protected override IEnumerable<NifPrefix> AllowedPrefixes => Prefixes.Where(p => p.Colectiva);
|
||||||
|
|
||||||
|
protected override string DefaultChoice => "5";
|
||||||
|
|
||||||
|
protected override (string, string)[] ExtraChoices => [("qualquer", "Qualquer")];
|
||||||
|
}
|
||||||
57
GeradoresService/Generators/Identificacao/NissGenerator.cs
Normal file
57
GeradoresService/Generators/Identificacao/NissGenerator.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Identificacao;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Número de Identificação da Segurança Social. 11 dígitos, começa por 1 (singular) ou 2 (colectiva).
|
||||||
|
/// Controlo: 9 - (Σ dígito × peso) mod 10, pesos 29, 23, 19, 17, 13, 11, 7, 5, 3, 2.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NissGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
private static readonly int[] Weights = [29, 23, 19, 17, 13, 11, 7, 5, 3, 2];
|
||||||
|
|
||||||
|
public override string Id => "niss";
|
||||||
|
public override string Name => "NISS";
|
||||||
|
public override string Category => Categories.Identificacao;
|
||||||
|
public override string Description => "Número de Identificação da Segurança Social.";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("tipo", "Tipo", "1", ("1", "1 — Pessoa singular"), ("2", "2 — Pessoa colectiva")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var body = context.Option(Options[0]) + context.Digits(9);
|
||||||
|
return new GeneratedItem(body + CheckDigit(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CheckDigit(ReadOnlySpan<char> first10)
|
||||||
|
{
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
sum += (first10[i] - '0') * Weights[i];
|
||||||
|
return 9 - sum % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var niss = Checksums.Clean(value);
|
||||||
|
if (niss.Length != 11 || !Checksums.AllDigits(niss))
|
||||||
|
return ValidationResult.WrongFormat("Um NISS tem 11 dígitos.");
|
||||||
|
if (niss[0] is not ('1' or '2'))
|
||||||
|
return ValidationResult.WrongFormat("Um NISS começa por 1 (singular) ou 2 (colectiva).");
|
||||||
|
|
||||||
|
var expected = CheckDigit(niss);
|
||||||
|
var tipo = niss[0] == '1' ? "pessoa singular" : "pessoa colectiva";
|
||||||
|
var details = new List<string>
|
||||||
|
{
|
||||||
|
$"Tipo: {tipo}",
|
||||||
|
$"Dígito de controlo esperado: {expected}",
|
||||||
|
};
|
||||||
|
|
||||||
|
return expected == niss[10] - '0'
|
||||||
|
? ValidationResult.Valid(niss, $"NISS válido — {tipo}.", details)
|
||||||
|
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {niss[10]}.", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Identificacao;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Número de passaporte português: uma letra seguida de 6 dígitos.
|
||||||
|
/// Não existe dígito de controlo público no número em si; valida-se apenas o formato.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class PassaporteGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "passaporte";
|
||||||
|
public override string Name => "Passaporte";
|
||||||
|
public override string Category => Categories.Identificacao;
|
||||||
|
public override string Description => "Número de passaporte português (letra + 6 dígitos). Apenas formato.";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context) =>
|
||||||
|
new($"{(char)('A' + context.Random.Next(26))}{context.Digits(6)}");
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var p = Checksums.Clean(value).ToUpperInvariant();
|
||||||
|
return Pattern().IsMatch(p)
|
||||||
|
? ValidationResult.Valid(p, "Formato de passaporte português válido (sem dígito de controlo).")
|
||||||
|
: ValidationResult.WrongFormat("Formato esperado: uma letra seguida de 6 dígitos (ex.: C123456).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("^[A-Z][0-9]{6}$")]
|
||||||
|
private static partial Regex Pattern();
|
||||||
|
}
|
||||||
53
GeradoresService/Generators/Outros/EanGenerator.cs
Normal file
53
GeradoresService/Generators/Outros/EanGenerator.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Outros;
|
||||||
|
|
||||||
|
/// <summary>Códigos de barras EAN-13 / EAN-8 (GS1). Controlo: pesos 3,1,3,1… a partir da direita.</summary>
|
||||||
|
public sealed class EanGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "ean";
|
||||||
|
public override string Name => "EAN / Código de barras";
|
||||||
|
public override string Category => Categories.Outros;
|
||||||
|
public override string Description => "Códigos EAN-13 e EAN-8 com dígito de controlo GS1.";
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("tipo", "Tipo", "ean13", ("ean13", "EAN-13"), ("ean8", "EAN-8")),
|
||||||
|
Option("prefixo", "Prefixo GS1", "560", ("560", "560 — Portugal"), ("qualquer", "Qualquer")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var length = context.Option(Options[0]) == "ean8" ? 8 : 13;
|
||||||
|
var prefix = context.Option(Options[1]) == "560" ? "560" : context.Digits(3);
|
||||||
|
var body = prefix + context.Digits(length - 1 - prefix.Length);
|
||||||
|
return new GeneratedItem(body + CheckDigit(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CheckDigit(string payload)
|
||||||
|
{
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < payload.Length; i++)
|
||||||
|
{
|
||||||
|
var weight = (payload.Length - i) % 2 == 1 ? 3 : 1;
|
||||||
|
sum += (payload[i] - '0') * weight;
|
||||||
|
}
|
||||||
|
return (10 - sum % 10) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var ean = Checksums.Clean(value);
|
||||||
|
if (ean.Length is not (8 or 13) || !Checksums.AllDigits(ean))
|
||||||
|
return ValidationResult.WrongFormat("Um EAN tem 8 ou 13 dígitos.");
|
||||||
|
|
||||||
|
var expected = CheckDigit(ean[..^1]);
|
||||||
|
var details = new List<string> { $"Tipo: EAN-{ean.Length}" };
|
||||||
|
if (ean.Length == 13 && ean.StartsWith("560"))
|
||||||
|
details.Add("Prefixo 560: Portugal");
|
||||||
|
|
||||||
|
return expected == ean[^1] - '0'
|
||||||
|
? ValidationResult.Valid(ean, $"EAN-{ean.Length} válido.", details)
|
||||||
|
: ValidationResult.BadChecksum($"Dígito de controlo inválido: esperado {expected}, encontrado {ean[^1]}.", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
GeradoresService/Generators/Outros/MatriculaGenerator.cs
Normal file
64
GeradoresService/Generators/Outros/MatriculaGenerator.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Outros;
|
||||||
|
|
||||||
|
/// <summary>Matrículas portuguesas em todos os formatos desde 1937. Apenas formato.</summary>
|
||||||
|
public sealed partial class MatriculaGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
// K, W e Y não eram usadas nas matrículas portuguesas.
|
||||||
|
private const string Letters = "ABCDEFGHIJLMNOPQRSTUVXZ";
|
||||||
|
|
||||||
|
private sealed record Format(string Key, string Label, string Mask, string Period);
|
||||||
|
|
||||||
|
private static readonly Format[] Formats =
|
||||||
|
[
|
||||||
|
new("2020", "AA-00-AA (desde 2020)", "LL-DD-LL", "2020 – actualidade"),
|
||||||
|
new("2005", "00-AA-00 (2005–2020)", "DD-LL-DD", "2005 – 2020"),
|
||||||
|
new("1992", "00-00-AA (1992–2005)", "DD-DD-LL", "1992 – 2005"),
|
||||||
|
new("1937", "AA-00-00 (1937–1992)", "LL-DD-DD", "1937 – 1992"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override string Id => "matricula";
|
||||||
|
public override string Name => "Matrícula";
|
||||||
|
public override string Category => Categories.Outros;
|
||||||
|
public override string Description => "Matrícula automóvel portuguesa em qualquer um dos formatos.";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("formato", "Formato", "2020", [.. Formats.Select(f => (f.Key, f.Label)), ("qualquer", "Qualquer")]),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var key = context.Option(Options[0]);
|
||||||
|
var format = key == "qualquer" ? context.Pick(Formats) : Formats.Single(f => f.Key == key);
|
||||||
|
var plate = string.Concat(format.Mask.Select(c => c switch
|
||||||
|
{
|
||||||
|
'L' => Letters[context.Random.Next(Letters.Length)],
|
||||||
|
'D' => (char)('0' + context.Random.Next(10)),
|
||||||
|
_ => c,
|
||||||
|
}));
|
||||||
|
return new GeneratedItem(plate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var plate = Checksums.Clean(value).ToUpperInvariant();
|
||||||
|
if (plate.Length != 6 || !plate.All(char.IsAsciiLetterOrDigit))
|
||||||
|
return ValidationResult.WrongFormat("Uma matrícula tem 6 caracteres em 3 pares (ex.: AA-00-AA).");
|
||||||
|
|
||||||
|
var mask = string.Concat(plate.Chunk(2).Select(p => p.All(char.IsAsciiDigit) ? "DD" : p.All(char.IsAsciiLetter) ? "LL" : "??"));
|
||||||
|
var masked = $"{mask[..2]}-{mask[2..4]}-{mask[4..]}";
|
||||||
|
var format = Formats.FirstOrDefault(f => f.Mask == masked);
|
||||||
|
if (format is null)
|
||||||
|
return ValidationResult.WrongFormat("Não corresponde a nenhum formato de matrícula portuguesa.");
|
||||||
|
|
||||||
|
var formatted = $"{plate[..2]}-{plate[2..4]}-{plate[4..]}";
|
||||||
|
if (plate.Any(c => c is 'K' or 'W' or 'Y'))
|
||||||
|
return ValidationResult.BadChecksum("As letras K, W e Y não são usadas em matrículas portuguesas.");
|
||||||
|
|
||||||
|
return ValidationResult.Valid(formatted, $"Matrícula válida (formato {format.Period}).", [$"Formato: {format.Label}"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
GeradoresService/Generators/Outros/UuidGenerator.cs
Normal file
50
GeradoresService/Generators/Outros/UuidGenerator.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace GeradoresService.Generators.Outros;
|
||||||
|
|
||||||
|
public sealed class UuidGenerator : GeneratorBase
|
||||||
|
{
|
||||||
|
public override string Id => "uuid";
|
||||||
|
public override string Name => "UUID / GUID";
|
||||||
|
public override string Category => Categories.Outros;
|
||||||
|
public override string Description => "Identificadores únicos UUID v4 (aleatório) ou v7 (ordenado por tempo).";
|
||||||
|
public override bool HasChecksum => false;
|
||||||
|
|
||||||
|
public override IReadOnlyList<GeneratorOption> Options =>
|
||||||
|
[
|
||||||
|
Option("versao", "Versão", "v4", ("v4", "v4 — aleatório"), ("v7", "v7 — ordenado por tempo")),
|
||||||
|
Option("formato", "Formato", "minusculas",
|
||||||
|
("minusculas", "minúsculas com hífens"), ("maiusculas", "MAIÚSCULAS com hífens"), ("compacto", "sem hífens")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public override GeneratedItem Generate(GeneratorContext context)
|
||||||
|
{
|
||||||
|
var guid = context.Option(Options[0]) == "v7" ? Guid.CreateVersion7() : NewV4(context.Random);
|
||||||
|
var text = context.Option(Options[1]) switch
|
||||||
|
{
|
||||||
|
"maiusculas" => guid.ToString("D").ToUpperInvariant(),
|
||||||
|
"compacto" => guid.ToString("N"),
|
||||||
|
_ => guid.ToString("D"),
|
||||||
|
};
|
||||||
|
return new GeneratedItem(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UUID v4 a partir de <paramref name="random"/> (permite resultados reprodutíveis com seed).</summary>
|
||||||
|
private static Guid NewV4(Random random)
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[16];
|
||||||
|
random.NextBytes(bytes);
|
||||||
|
// Versão 4 (nibble alto do byte 7 no layout do System.Guid) e variante RFC 9562.
|
||||||
|
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x40);
|
||||||
|
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValidationResult Validate(string value)
|
||||||
|
{
|
||||||
|
var v = value.Trim().Trim('{', '}');
|
||||||
|
if (v.Length is not (32 or 36) || !Guid.TryParse(v, out var guid))
|
||||||
|
return ValidationResult.WrongFormat("Não é um UUID válido.");
|
||||||
|
return ValidationResult.Valid(guid.ToString("D"), $"UUID válido (versão {guid.Version}).", [$"Versão: {guid.Version}", $"Variante: {guid.Variant}"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="DAL\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
using GeradoresService.DAL;
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace GeradoresService
|
|
||||||
{
|
|
||||||
public class NIF
|
|
||||||
{
|
|
||||||
private readonly GeradoresContext _geradoresContext;
|
|
||||||
|
|
||||||
public NIF(GeradoresContext geradoresContext)
|
|
||||||
{
|
|
||||||
_geradoresContext = geradoresContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
|
||||||
public enum NIFType
|
|
||||||
{
|
|
||||||
[Description("Pessoa singular (1)")]
|
|
||||||
PessoaSingular1 = 1,
|
|
||||||
|
|
||||||
[Description("Pessoa singular (2)")]
|
|
||||||
PessoaSingular2 = 2,
|
|
||||||
|
|
||||||
[Description("Pessoa singular (3; novo em 2019)")]
|
|
||||||
PessoaSingular3 = 3,
|
|
||||||
|
|
||||||
[Description("Pessoa colectiva (5)")]
|
|
||||||
PessoaColectiva = 5,
|
|
||||||
|
|
||||||
[Description("Pessoa colectiva pública (6)")]
|
|
||||||
PessoaColectivaPublica = 6,
|
|
||||||
|
|
||||||
[Description("Empresário em nome individual (8)")]
|
|
||||||
EmpresarioIndividual = 8,
|
|
||||||
|
|
||||||
[Description("Pessoa colectiva irregular ou número provisório (9)")]
|
|
||||||
PessoaColectivaIrregular = 9
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Generate(NIFType? type)
|
|
||||||
{
|
|
||||||
var nif = GenerateRandomNIF(type);
|
|
||||||
return nif;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GenerateRandomNIF(NIFType? nifType)
|
|
||||||
{
|
|
||||||
char firstDigit;
|
|
||||||
Random rnd = new Random();
|
|
||||||
|
|
||||||
if (nifType.HasValue)
|
|
||||||
{
|
|
||||||
firstDigit = ((int)nifType.Value).ToString()[0];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var validFirstDigits = Enum.GetValues(typeof(NIFType)).Cast<int>().Select(v => v.ToString()[0]).ToArray();
|
|
||||||
firstDigit = validFirstDigits[rnd.Next(validFirstDigits.Length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
string nextDigits = string.Concat(Enumerable.Range(0, 7).Select(_ => rnd.Next(0, 10).ToString()));
|
|
||||||
|
|
||||||
int checkDigit = (firstDigit - '0') * 9;
|
|
||||||
for (int i = 0; i < 7; i++)
|
|
||||||
{
|
|
||||||
checkDigit += (nextDigits[i] - '0') * (8 - i);
|
|
||||||
}
|
|
||||||
checkDigit = 11 - (checkDigit % 11);
|
|
||||||
if (checkDigit >= 10)
|
|
||||||
checkDigit = 0;
|
|
||||||
|
|
||||||
return firstDigit + nextDigits + checkDigit;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Validate(string nif)
|
|
||||||
{
|
|
||||||
if (nif.Length != 9 || !nif.All(char.IsDigit))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
int[] digitos = nif.Select(c => c - '0').ToArray();
|
|
||||||
int soma = digitos.Take(8).Select((num, i) => num * (9 - i)).Sum();
|
|
||||||
int digitoControlo = (11 - (soma % 11)) % 10;
|
|
||||||
|
|
||||||
return digitoControlo == digitos[8];
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SaveNIF(string NIF)
|
|
||||||
{
|
|
||||||
var ger = new Geradore() { Valor = NIF };
|
|
||||||
_geradoresContext.Geradores.Add(ger);
|
|
||||||
_geradoresContext.SaveChanges();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GeradoresService
|
|
||||||
{
|
|
||||||
public class NISS
|
|
||||||
{
|
|
||||||
private static readonly Random random = new Random();
|
|
||||||
public static string Generate() {
|
|
||||||
return GenerateNISS();
|
|
||||||
}
|
|
||||||
private static string GenerateRandomDigits(int length)
|
|
||||||
{
|
|
||||||
string digits = "1";
|
|
||||||
for (int i = 0; i < length; i++)
|
|
||||||
{
|
|
||||||
digits += random.Next(0, 10); // Gera um dígito aleatório entre 0 e 9
|
|
||||||
}
|
|
||||||
return digits;
|
|
||||||
}
|
|
||||||
public static string GenerateNISS()
|
|
||||||
{
|
|
||||||
// Gera 9 dígitos aleatórios
|
|
||||||
string niss = GenerateRandomDigits(9);
|
|
||||||
|
|
||||||
// Calcula o dígito de controlo
|
|
||||||
int soma = 0;
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
{
|
|
||||||
int digito = int.Parse(niss[i].ToString());
|
|
||||||
soma += digito * (i + 1);
|
|
||||||
}
|
|
||||||
int resto = soma % 11;
|
|
||||||
int digitoControlo = 11 - resto;
|
|
||||||
|
|
||||||
// Verifica se o dígito de controlo está no intervalo [0, 9]
|
|
||||||
if (digitoControlo == 10)
|
|
||||||
{
|
|
||||||
niss += "0";
|
|
||||||
}
|
|
||||||
else if (digitoControlo == 11)
|
|
||||||
{
|
|
||||||
niss += "1";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
niss += digitoControlo.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return niss;
|
|
||||||
}
|
|
||||||
public static bool Validate(string niss)
|
|
||||||
{
|
|
||||||
// Verifica se o NISS tem 11 dígitos
|
|
||||||
if (niss.Length != 11)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Verifica se é um número
|
|
||||||
foreach (char c in niss)
|
|
||||||
{
|
|
||||||
if (!char.IsDigit(c))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcula o dígito de controlo
|
|
||||||
int soma = 0;
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
{
|
|
||||||
int digito = int.Parse(niss[i].ToString());
|
|
||||||
soma += digito * (i + 1);
|
|
||||||
}
|
|
||||||
int resto = soma % 11;
|
|
||||||
int digitoControlo = 11 - resto;
|
|
||||||
|
|
||||||
// Verifica o último dígito do NISS
|
|
||||||
int ultimoDigito = int.Parse(niss[10].ToString());
|
|
||||||
if (digitoControlo == 10 && ultimoDigito != 0)
|
|
||||||
return false;
|
|
||||||
if (digitoControlo == 11 && ultimoDigito != 0 && ultimoDigito != 1)
|
|
||||||
return false;
|
|
||||||
if (digitoControlo != ultimoDigito)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
GeradoresService/README.md
Normal file
71
GeradoresService/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# GeradoresService
|
||||||
|
|
||||||
|
Biblioteca .NET 10 com os algoritmos de geração e validação. Não tem dependências externas e é usada pela API
|
||||||
|
([GeradoresWS](../GeradoresWS/README.md)) e pelos testes ([UnitTest](../UnitTest/README.md)).
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
| Caminho | Conteúdo |
|
||||||
|
|---|---|
|
||||||
|
| `Core/IGenerator.cs` | Contrato de um gerador/validador e as categorias |
|
||||||
|
| `Core/GeneratorBase.cs` | Classe base com valores por omissão e o helper `Option(...)` |
|
||||||
|
| `Core/Models.cs` | `GeneratorOption`, `GeneratedItem`, `ValidationResult`, `GeneratorContext` |
|
||||||
|
| `Core/Checksums.cs` | Algoritmos partilhados: módulo 11, Luhn (com letras), MOD 97-10, limpeza de input |
|
||||||
|
| `Data/PtData.cs` | Nomes, apelidos, localidades, bancos e outros dados portugueses fictícios |
|
||||||
|
| `Generators/<Categoria>/` | Um ficheiro por tipo |
|
||||||
|
| `GeneratorRegistry.cs` | Lista de geradores, geração em lote e detecção automática do tipo |
|
||||||
|
|
||||||
|
## Geradores
|
||||||
|
|
||||||
|
| Id | Nome | Categoria | Validação |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nif` | NIF | Identificação | Módulo 11, pesos 9..2 |
|
||||||
|
| `nipc` | NIPC | Identificação | Igual ao NIF, só prefixos de pessoa colectiva |
|
||||||
|
| `niss` | NISS | Identificação | 9 − (Σ dígito × peso) mod 10, pesos 29, 23, 19, 17, 13, 11, 7, 5, 3, 2 |
|
||||||
|
| `cc` | Cartão de Cidadão | Identificação | Módulo 11 no NIC + Luhn (A=10..Z=35) no documento |
|
||||||
|
| `passaporte` | Passaporte | Identificação | Só formato (letra + 6 dígitos) |
|
||||||
|
| `iban` | IBAN | Financeiro | ISO 7064 MOD 97-10; aceita IBAN de qualquer país |
|
||||||
|
| `nib` | NIB | Financeiro | 98 − (NIB19 × 100 mod 97) |
|
||||||
|
| `cartao-credito` | Cartão de crédito | Financeiro | Luhn |
|
||||||
|
| `multibanco` | Referência Multibanco | Financeiro | 98 − (Σ dígito × peso mod 97) sobre entidade + referência + montante |
|
||||||
|
| `pessoa` | Pessoa fictícia | Contactos e Morada | Não valida (gerador composto) |
|
||||||
|
| `telefone` | Telefone | Contactos e Morada | Só formato (Plano Nacional de Numeração) |
|
||||||
|
| `codigo-postal` | Código postal | Contactos e Morada | Só formato (NNNN-NNN) |
|
||||||
|
| `email` | Email | Contactos e Morada | Só formato |
|
||||||
|
| `matricula` | Matrícula | Outros | Só formato (todos os formatos desde 1937) |
|
||||||
|
| `ean` | EAN / Código de barras | Outros | Dígito GS1 (pesos 3,1,3,1…) |
|
||||||
|
| `uuid` | UUID / GUID | Outros | Só formato (v4 ou v7) |
|
||||||
|
|
||||||
|
`HasChecksum` indica se a validação verifica um dígito de controlo ou apenas o formato; `CanValidate` é
|
||||||
|
`false` para geradores compostos como `pessoa`.
|
||||||
|
|
||||||
|
## Utilização
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var registry = new GeneratorRegistry();
|
||||||
|
var nif = registry.Find("nif")!;
|
||||||
|
|
||||||
|
// 10 NIF de pessoa colectiva, reprodutíveis com seed
|
||||||
|
var items = registry.Generate(nif, 10, new Dictionary<string, string?> { ["tipo"] = "5" }, seed: 42);
|
||||||
|
|
||||||
|
// Validação de um tipo
|
||||||
|
ValidationResult r = nif.Validate("123456789"); // IsValid, FormatMatch, Message, Normalized, Details
|
||||||
|
|
||||||
|
// Detecção automática: válidos primeiro, depois os que têm o formato mas falham o controlo
|
||||||
|
var matches = registry.Detect("PT50 0002 0123 1234 5678 9015 4");
|
||||||
|
```
|
||||||
|
|
||||||
|
- O lote vai de 1 a `GeneratorRegistry.MaxBatch` (1000); fora disso lança `ArgumentOutOfRangeException`.
|
||||||
|
- Um valor de opção que não esteja nas escolhas do gerador lança `ArgumentException`.
|
||||||
|
- `ValidationResult.Details` traz os passos do cálculo, para explicar porque um valor é (in)válido.
|
||||||
|
|
||||||
|
## Adicionar um gerador
|
||||||
|
|
||||||
|
1. Criar uma classe em `Generators/<Categoria>/` que herde de `GeneratorBase` e implemente `Id`, `Name`,
|
||||||
|
`Category`, `Description`, `Generate` e `Validate`. As opções declaram-se com `Option(key, label, default, choices...)`
|
||||||
|
e lêem-se com `context.Option(Options[i])`.
|
||||||
|
2. Usar sempre `context.Random` (nunca `Random.Shared`), para que o `seed` produza resultados reprodutíveis.
|
||||||
|
3. Registar a instância no construtor de `GeneratorRegistry`.
|
||||||
|
|
||||||
|
O novo tipo aparece automaticamente na API e no frontend, e o `GeneratorRoundTripTests` passa a gerá-lo com
|
||||||
|
todas as opções e a confirmar que o validador aceita os valores gerados.
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
public static class EnumHelper
|
|
||||||
{
|
|
||||||
public static List<(T Value, string Description)> GetEnumValuesAndDescriptions<T>() where T : Enum
|
|
||||||
{
|
|
||||||
return typeof(T).GetFields()
|
|
||||||
.Select(field => (
|
|
||||||
Value: (T)field.GetValue(null),
|
|
||||||
Description: field.GetCustomAttribute<DescriptionAttribute>()?.Description ?? field.Name
|
|
||||||
))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using GeradoresService;
|
|
||||||
using GeradoresService.DAL;
|
|
||||||
|
|
||||||
namespace GeradoresWS.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("[controller]")]
|
|
||||||
public class GenerateController : Controller
|
|
||||||
{
|
|
||||||
GeradoresContext _context;
|
|
||||||
public GenerateController(GeradoresContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region NIF
|
|
||||||
[HttpGet("GenerateNIF")]
|
|
||||||
public string GenerateNIF(NIF.NIFType type)
|
|
||||||
{
|
|
||||||
var nif = new NIF(_context);
|
|
||||||
return nif.Generate(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("ValidateNIF")]
|
|
||||||
public bool ValidateNIF(string nif)
|
|
||||||
{
|
|
||||||
var teste = new NIF(_context);
|
|
||||||
return teste.Validate(nif);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region NISS
|
|
||||||
[HttpGet("GenerateNISS")]
|
|
||||||
public string GenerateNISS()
|
|
||||||
{
|
|
||||||
return NISS.Generate();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("ValidateNISS")]
|
|
||||||
public bool ValidateNISS(string nif)
|
|
||||||
{
|
|
||||||
return NISS.Validate(nif);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region CC
|
|
||||||
[HttpGet("GenerateCC")]
|
|
||||||
public string GenerateCC()
|
|
||||||
{
|
|
||||||
return CartaoCidadao.Generate();
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GeradoresWS.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("[controller]")]
|
|
||||||
public class WeatherForecastController : ControllerBase
|
|
||||||
{
|
|
||||||
private static readonly string[] Summaries = new[]
|
|
||||||
{
|
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly ILogger<WeatherForecastController> _logger;
|
|
||||||
|
|
||||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet(Name = "GetWeatherForecast")]
|
|
||||||
public IEnumerable<WeatherForecast> Get()
|
|
||||||
{
|
|
||||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
|
||||||
{
|
|
||||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
||||||
TemperatureC = Random.Shared.Next(-20, 55),
|
|
||||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
|
||||||
})
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,26 @@
|
|||||||
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
# Usado pelo Visual Studio (perfil "Docker"). Igual ao Dockerfile da raiz, com o stage "base" que o VS espera.
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 8080
|
|
||||||
EXPOSE 443
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
|
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
|
||||||
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
|
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
|
||||||
|
|
||||||
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
|
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
|
||||||
COPY . .
|
|
||||||
|
|
||||||
WORKDIR "/src/GeradoresWS"
|
COPY GeradoresService/ GeradoresService/
|
||||||
RUN dotnet build "GeradoresWS.csproj" -c debug -o /app/build
|
COPY GeradoresWS/ GeradoresWS/
|
||||||
|
RUN dotnet publish "GeradoresWS/GeradoresWS.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
|
||||||
|
|
||||||
FROM build AS publish
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
RUN dotnet publish "GeradoresWS.csproj" -c debug -o /app/publish /p:UseAppHost=false
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
# Set environment variables
|
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
EXPOSE 8080
|
||||||
|
USER $APP_UID
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]
|
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
using Microsoft.OpenApi.Any;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
|
||||||
using System.ComponentModel;
|
|
||||||
|
|
||||||
namespace Primus.ProfitabilityModel.WebAPI.Filters
|
|
||||||
{
|
|
||||||
public class EnumDescriptionFilter : ISchemaFilter
|
|
||||||
{
|
|
||||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
|
||||||
{
|
|
||||||
if (context.Type.IsEnum)
|
|
||||||
{
|
|
||||||
// Obter os valores do enum
|
|
||||||
var enumValues = Enum.GetValues(context.Type)
|
|
||||||
.Cast<Enum>()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Obter os nomes dos enums
|
|
||||||
var enumNames = enumValues.Select(e => e.ToString()).ToList();
|
|
||||||
|
|
||||||
// Obter as descrições dos enums
|
|
||||||
var enumDescriptions = enumValues.Select(e => GetEnumDescription(e)).ToList();
|
|
||||||
|
|
||||||
// Adicionar as extensões com os nomes e descrições
|
|
||||||
var enumNamesArray = new OpenApiArray();
|
|
||||||
foreach (var name in enumNames)
|
|
||||||
{
|
|
||||||
enumNamesArray.Add(new OpenApiString(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
var enumDescriptionsArray = new OpenApiArray();
|
|
||||||
foreach (var description in enumDescriptions)
|
|
||||||
{
|
|
||||||
enumDescriptionsArray.Add(new OpenApiString(description));
|
|
||||||
}
|
|
||||||
|
|
||||||
schema.Extensions.Add("x-enumNames", enumDescriptionsArray);
|
|
||||||
schema.Extensions.Add("x-enumDescriptions", enumDescriptionsArray);
|
|
||||||
|
|
||||||
// Adicionar os valores do enum ao campo Enum
|
|
||||||
schema.Enum = enumNames
|
|
||||||
.Select(name => new OpenApiString(name))
|
|
||||||
.Cast<IOpenApiAny>()
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetEnumDescription(Enum value)
|
|
||||||
{
|
|
||||||
// Verificar se o campo é nulo antes de chamar GetCustomAttribute
|
|
||||||
var field = value.GetType().GetField(value.ToString());
|
|
||||||
if (field == null)
|
|
||||||
{
|
|
||||||
return value.ToString(); // Se o campo for nulo, retorna o nome do valor do enum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acessa o atributo Description, caso exista
|
|
||||||
var attribute = (DescriptionAttribute)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute));
|
|
||||||
|
|
||||||
// Retorna a descrição ou o nome do valor do enum, se a descrição não estiver presente
|
|
||||||
return attribute?.Description ?? value.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
GeradoresWS/GeneratorEndpoints.cs
Normal file
101
GeradoresWS/GeneratorEndpoints.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using GeradoresService;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
|
namespace GeradoresWS;
|
||||||
|
|
||||||
|
public static class GeneratorEndpoints
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ReservedQueryKeys = new(StringComparer.OrdinalIgnoreCase) { "count", "seed" };
|
||||||
|
|
||||||
|
public static void MapGeneratorEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var api = app.MapGroup("/api/v1").WithTags("Geradores");
|
||||||
|
|
||||||
|
api.MapGet("/generators", (GeneratorRegistry registry) =>
|
||||||
|
registry.All.Select(GeneratorDto.From).ToList())
|
||||||
|
.WithName("ListGenerators")
|
||||||
|
.WithSummary("Lista todos os geradores e as respectivas opções.");
|
||||||
|
|
||||||
|
api.MapGet("/generators/{id}", Results<Ok<GeneratorDto>, NotFound> (string id, GeneratorRegistry registry) =>
|
||||||
|
registry.Find(id) is { } g ? TypedResults.Ok(GeneratorDto.From(g)) : TypedResults.NotFound())
|
||||||
|
.WithName("GetGenerator")
|
||||||
|
.WithSummary("Detalhe de um gerador.");
|
||||||
|
|
||||||
|
api.MapGet("/generators/{id}/generate", Generate)
|
||||||
|
.WithName("Generate")
|
||||||
|
.WithSummary("Gera um ou mais valores.")
|
||||||
|
.WithDescription("As opções do gerador passam-se na query string, ex.: /generators/nif/generate?count=10&tipo=5");
|
||||||
|
|
||||||
|
api.MapGet("/generators/{id}/validate",
|
||||||
|
Results<Ok<ValidationResult>, NotFound> (string id, [Description("Valor a validar")] string value, GeneratorRegistry registry) =>
|
||||||
|
registry.Find(id) is { CanValidate: true } g
|
||||||
|
? TypedResults.Ok(g.Validate(value))
|
||||||
|
: TypedResults.NotFound())
|
||||||
|
.WithName("Validate")
|
||||||
|
.WithSummary("Valida um valor de um tipo específico.");
|
||||||
|
|
||||||
|
api.MapGet("/validate", (string value, GeneratorRegistry registry) =>
|
||||||
|
new DetectResponse(value, registry.Detect(value)
|
||||||
|
.Select(m => new DetectMatchDto(m.Generator.Id, m.Generator.Name, m.Result))
|
||||||
|
.ToList()))
|
||||||
|
.WithName("Detect")
|
||||||
|
.WithSummary("Validador livre: identifica o tipo do valor e diz se é válido.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Results<Ok<GenerateResponse>, NotFound, ValidationProblem> Generate(
|
||||||
|
string id,
|
||||||
|
HttpRequest request,
|
||||||
|
GeneratorRegistry registry,
|
||||||
|
[Description("Quantidade (1–1000)")] int count = 1,
|
||||||
|
[Description("Seed para resultados reprodutíveis")] int? seed = null)
|
||||||
|
{
|
||||||
|
var generator = registry.Find(id);
|
||||||
|
if (generator is null)
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var options = request.Query
|
||||||
|
.Where(q => !ReservedQueryKeys.Contains(q.Key))
|
||||||
|
.ToDictionary(q => q.Key, q => (string?)q.Value.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var unknown = options.Keys.Where(k => generator.Options.All(o => !string.Equals(o.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||||
|
if (unknown.Count > 0)
|
||||||
|
return Problem(unknown[0], $"Opção desconhecida. Opções válidas: {string.Join(", ", generator.Options.Select(o => o.Key))}.");
|
||||||
|
|
||||||
|
if (count is < 1 or > GeneratorRegistry.MaxBatch)
|
||||||
|
return Problem("count", $"A quantidade deve estar entre 1 e {GeneratorRegistry.MaxBatch}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = registry.Generate(generator, count, options, seed);
|
||||||
|
return TypedResults.Ok(new GenerateResponse(generator.Id, items));
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return Problem("options", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValidationProblem Problem(string key, string message) =>
|
||||||
|
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [key] = [message] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GeneratorDto(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Category,
|
||||||
|
string Description,
|
||||||
|
bool HasChecksum,
|
||||||
|
bool CanValidate,
|
||||||
|
IReadOnlyList<GeneratorOption> Options)
|
||||||
|
{
|
||||||
|
public static GeneratorDto From(IGenerator g) =>
|
||||||
|
new(g.Id, g.Name, g.Category, g.Description, g.HasChecksum, g.CanValidate, g.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GenerateResponse(string Generator, IReadOnlyList<GeneratedItem> Items);
|
||||||
|
|
||||||
|
public sealed record DetectMatchDto(string GeneratorId, string GeneratorName, ValidationResult Result);
|
||||||
|
|
||||||
|
public sealed record DetectResponse(string Value, IReadOnlyList<DetectMatchDto> Matches);
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>df357c84-88b1-4100-86d1-889166b333f7</UserSecretsId>
|
<UserSecretsId>df357c84-88b1-4100-86d1-889166b333f7</UserSecretsId>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.17.14" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,56 +1,40 @@
|
|||||||
using GeradoresService.DAL;
|
using GeradoresService;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using GeradoresWS;
|
||||||
using Microsoft.OpenApi.Models;
|
using Scalar.AspNetCore;
|
||||||
using Primus.ProfitabilityModel.WebAPI.Filters;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
builder.Services.AddSingleton<GeneratorRegistry>();
|
||||||
builder.Services.AddControllers().AddJsonOptions(options =>
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddHealthChecks();
|
||||||
|
builder.Services.AddOpenApi(options =>
|
||||||
{
|
{
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.AddDocumentTransformer((document, _, _) =>
|
||||||
});
|
|
||||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
|
||||||
options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
|
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
|
||||||
{
|
{
|
||||||
|
document.Info.Title = "FactoryId API";
|
||||||
Version = "v1",
|
document.Info.Description = "Geradores e validadores de identificadores portugueses (NIF, NISS, CC, IBAN, …) para testes.";
|
||||||
Title = "FactoryId",
|
document.Info.Contact = new() { Name = "Marco Santos" };
|
||||||
Contact = new OpenApiContact
|
return Task.CompletedTask;
|
||||||
{
|
|
||||||
Name = "Marco Santos"
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Registra o filtro de esquema que aplica descrições para todos os enums
|
|
||||||
options.SchemaFilter<EnumDescriptionFilter>();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API pública, só de leitura e sem credenciais: qualquer origem pode fazer GET.
|
||||||
var connectionString = builder.Configuration.GetConnectionString("GeradoresConnection");
|
builder.Services.AddCors(options =>
|
||||||
builder.Services.AddDbContext<GeradoresContext>(options => options.UseSqlServer(connectionString));
|
options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().WithMethods("GET").AllowAnyHeader()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
app.UseExceptionHandler();
|
||||||
app.UseSwagger();
|
app.UseStatusCodePages();
|
||||||
app.UseSwaggerUI();
|
app.UseCors();
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference("/docs", options => options.WithTitle("FactoryId API"));
|
||||||
|
app.MapGet("/", () => Results.Redirect("/docs")).ExcludeFromDescription();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.UseCors(options =>
|
app.MapGeneratorEndpoints();
|
||||||
{
|
|
||||||
options.AllowAnyOrigin();
|
|
||||||
options.AllowAnyMethod();
|
|
||||||
options.AllowAnyHeader();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "docs",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"https": {
|
"https": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "docs",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"IIS Express": {
|
"IIS Express": {
|
||||||
"commandName": "IISExpress",
|
"commandName": "IISExpress",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "swagger",
|
"launchUrl": "docs",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"Docker": {
|
"Docker": {
|
||||||
"commandName": "Docker",
|
"commandName": "Docker",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/docs",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_URLS": "http://+:8080",
|
"ASPNETCORE_URLS": "http://+:8080",
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
|||||||
64
GeradoresWS/README.md
Normal file
64
GeradoresWS/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# GeradoresWS
|
||||||
|
|
||||||
|
API REST ASP.NET Core (.NET 10, minimal APIs) que expõe os geradores do
|
||||||
|
[GeradoresService](../GeradoresService/README.md). É só de leitura, não tem autenticação nem base de dados.
|
||||||
|
|
||||||
|
## Executar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project GeradoresWS --launch-profile http
|
||||||
|
# http://localhost:5053 → redirecciona para /docs
|
||||||
|
```
|
||||||
|
|
||||||
|
| Perfil (`launchSettings.json`) | URL |
|
||||||
|
|---|---|
|
||||||
|
| `http` | http://localhost:5053 |
|
||||||
|
| `https` | https://localhost:7266 e http://localhost:5053 |
|
||||||
|
| `Docker` | Visual Studio, usa o `Dockerfile` desta pasta |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/generators` | Lista os geradores e as opções de cada um |
|
||||||
|
| GET | `/api/v1/generators/{id}` | Detalhe de um gerador (404 se não existir) |
|
||||||
|
| GET | `/api/v1/generators/{id}/generate` | Gera valores. Query: `count` (1–1000, por omissão 1), `seed` (opcional) e as opções do gerador |
|
||||||
|
| GET | `/api/v1/generators/{id}/validate?value=...` | Valida um valor de um tipo (404 se o tipo não validar) |
|
||||||
|
| GET | `/api/v1/validate?value=...` | Validador livre: detecta o tipo e valida |
|
||||||
|
| GET | `/openapi/v1.json` | Documento OpenAPI |
|
||||||
|
| GET | `/docs` | Documentação interactiva (Scalar) |
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:5053/api/v1/generators/nif/generate?count=5&tipo=5"
|
||||||
|
curl "http://localhost:5053/api/v1/generators/iban/validate?value=PT50000201231234567890154"
|
||||||
|
curl "http://localhost:5053/api/v1/validate?value=123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erros de input (opção desconhecida, valor de opção inválido, `count` fora do intervalo) devolvem
|
||||||
|
`400` com `ValidationProblemDetails`; as restantes falhas devolvem `ProblemDetails`.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
- **CORS**: qualquer origem pode fazer `GET` (API pública, sem credenciais).
|
||||||
|
- **Porta em container**: `8080` (`ASPNETCORE_HTTP_PORTS`).
|
||||||
|
- `InvariantGlobalization` está activo, por isso a imagem não precisa de ICU.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
O `Dockerfile` da raiz do repositório é o usado no `docker compose` e no Jenkins. O `Dockerfile` desta pasta é
|
||||||
|
equivalente, com o stage `base` que o Visual Studio espera. Em ambos o contexto de build é a raiz do repositório:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t geradoresws .
|
||||||
|
docker run -p 5050:8080 geradoresws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ficheiros
|
||||||
|
|
||||||
|
| Ficheiro | Conteúdo |
|
||||||
|
|---|---|
|
||||||
|
| `Program.cs` | Serviços (registry, OpenAPI, CORS, health checks) e pipeline |
|
||||||
|
| `GeneratorEndpoints.cs` | Rotas `/api/v1` e DTOs de resposta |
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace GeradoresWS
|
|
||||||
{
|
|
||||||
public class WeatherForecast
|
|
||||||
{
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureC { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,5 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*"
|
||||||
"ConnectionStrings": {
|
|
||||||
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
18
Jenkinsfile
vendored
18
Jenkinsfile
vendored
@@ -6,8 +6,6 @@ pipeline {
|
|||||||
DOCKER_TAG = "${env.BUILD_ID}"
|
DOCKER_TAG = "${env.BUILD_ID}"
|
||||||
|
|
||||||
REACT_DIR = 'geradoresfe'
|
REACT_DIR = 'geradoresfe'
|
||||||
SERVICE_DIR = 'GeradoresService'
|
|
||||||
API_DIR = 'GeradoresWS'
|
|
||||||
|
|
||||||
REACT_IMAGE = "${DOCKER_REGISTRY}/geradores/fe:${DOCKER_TAG}"
|
REACT_IMAGE = "${DOCKER_REGISTRY}/geradores/fe:${DOCKER_TAG}"
|
||||||
WS_IMAGE = "${DOCKER_REGISTRY}/geradores/ws:${DOCKER_TAG}"
|
WS_IMAGE = "${DOCKER_REGISTRY}/geradores/ws:${DOCKER_TAG}"
|
||||||
@@ -36,21 +34,19 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build .NET Services') {
|
stage('Testes .NET') {
|
||||||
steps {
|
steps {
|
||||||
dir("${SERVICE_DIR}") {
|
// Compila Service + WS e corre os testes (requer .NET 10 SDK no agente).
|
||||||
sh 'dotnet build -c Release'
|
sh 'dotnet test UnitTest/UnitTest.csproj -c Release'
|
||||||
}
|
|
||||||
dir("${API_DIR}") {
|
|
||||||
sh 'dotnet build -c Release'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build React App') {
|
stage('Testes e Build React') {
|
||||||
steps {
|
steps {
|
||||||
dir("${REACT_DIR}") {
|
dir("${REACT_DIR}") {
|
||||||
sh 'npm install'
|
sh 'npm ci'
|
||||||
|
sh 'npm run lint'
|
||||||
|
sh 'npm test'
|
||||||
sh 'npm run build'
|
sh 'npm run build'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -1,7 +1,65 @@
|
|||||||
# Geradores
|
# Factory Id — Geradores
|
||||||
|
|
||||||
<code> docker build -t shini89/geradoresws ..</code>
|
Geradores e validadores de dados portugueses para testes de software: NIF, NIPC, NISS, Cartão de Cidadão,
|
||||||
<code> docker run --rm -p 44329:8080 shini89/geradoresws</code>
|
IBAN/NIB, cartões de pagamento, referências Multibanco, telefones, códigos postais, emails, pessoas fictícias,
|
||||||
<code>npx swagger-typescript-api generate -p http://localhost:29191/swagger/v1/swagger.json -o src/service -n api.ts --enum-names-as-values</code>
|
matrículas, EAN e UUID.
|
||||||
|
|
||||||
npx swagger-typescript-api generate -p http://localhost:29191/swagger/v1/swagger.json -o src/service -n api.ts --http-client axios --enum-names-as-values
|
- **Geração em lote** (até 1000 de cada vez) com exportação CSV, JSON e TXT
|
||||||
|
- **Validador livre**: cola-se um valor qualquer, o tipo é detectado e é explicado porque é (in)válido
|
||||||
|
- **API REST** documentada em `/docs`
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
| Pasta | Conteúdo |
|
||||||
|
|---|---|
|
||||||
|
| [`GeradoresService/`](GeradoresService/README.md) | Biblioteca com os algoritmos. Cada tipo é uma classe `IGenerator` em `Generators/` |
|
||||||
|
| [`GeradoresWS/`](GeradoresWS/README.md) | API ASP.NET Core (.NET 10, minimal APIs, OpenAPI + Scalar) |
|
||||||
|
| [`UnitTest/`](UnitTest/README.md) | Testes xUnit (algoritmos, valores conhecidos e API) |
|
||||||
|
| [`geradoresfe/`](geradoresfe/README.md) | Frontend React + Vite + Tailwind. A UI é construída a partir dos metadados da API |
|
||||||
|
|
||||||
|
## Desenvolvimento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API em http://localhost:5053 (docs em /docs)
|
||||||
|
dotnet run --project GeradoresWS --launch-profile http
|
||||||
|
|
||||||
|
# Frontend em http://localhost:3000 (faz proxy de /api para a API)
|
||||||
|
cd geradoresfe && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Testes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test UnitTest
|
||||||
|
cd geradoresfe && npm test && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# Frontend: http://localhost:3000 API directa: http://localhost:5050/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
O nginx do frontend encaminha `/api`, `/docs` e `/openapi` para `API_UPSTREAM`
|
||||||
|
(por omissão `http://geradoresws:8080`), por isso a mesma imagem funciona em qualquer ambiente
|
||||||
|
sem rebuild.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/generators` | Lista de geradores e opções |
|
||||||
|
| GET | `/api/v1/generators/{id}/generate?count=10&<opção>=<valor>` | Gera valores (`seed` opcional para resultados reprodutíveis) |
|
||||||
|
| GET | `/api/v1/generators/{id}/validate?value=...` | Valida um valor de um tipo |
|
||||||
|
| GET | `/api/v1/validate?value=...` | Detecta o tipo e valida |
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
|
||||||
|
Exemplo: `GET /api/v1/generators/nif/generate?count=5&tipo=5` gera 5 NIF de pessoa colectiva.
|
||||||
|
|
||||||
|
## Adicionar um gerador
|
||||||
|
|
||||||
|
1. Criar uma classe em `GeradoresService/Generators/<Categoria>/` que herde de `GeneratorBase`.
|
||||||
|
2. Registá-la em `GeneratorRegistry`.
|
||||||
|
3. Pronto: aparece na API, no frontend e é coberta automaticamente pelo teste de ida e volta
|
||||||
|
(`GeneratorRoundTripTests`), que gera valores com todas as opções e confirma que o validador os aceita.
|
||||||
|
|||||||
66
UnitTest/ApiTests.cs
Normal file
66
UnitTest/ApiTests.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace UnitTest;
|
||||||
|
|
||||||
|
public class ApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lists_generators()
|
||||||
|
{
|
||||||
|
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators");
|
||||||
|
Assert.Contains(json.EnumerateArray(), g => g.GetProperty("id").GetString() == "nif");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generates_batch_with_options()
|
||||||
|
{
|
||||||
|
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/generate?count=25&tipo=5&seed=1");
|
||||||
|
var items = json.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
Assert.Equal(25, items.Count);
|
||||||
|
Assert.All(items, i => Assert.StartsWith("5", i.GetProperty("value").GetString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/v1/generators/nif/generate?count=0")]
|
||||||
|
[InlineData("/api/v1/generators/nif/generate?count=5000")]
|
||||||
|
[InlineData("/api/v1/generators/nif/generate?tipo=xpto")]
|
||||||
|
[InlineData("/api/v1/generators/nif/generate?desconhecida=1")]
|
||||||
|
public async Task Bad_requests(string url)
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(url);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_generator_is_404()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/api/v1/generators/xpto/generate");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Validates_value()
|
||||||
|
{
|
||||||
|
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/validate?value=123456789");
|
||||||
|
Assert.True(json.GetProperty("isValid").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Detects_value()
|
||||||
|
{
|
||||||
|
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/validate?value=" + Uri.EscapeDataString("PT50 0002 0123 1234 5678 9015 4"));
|
||||||
|
Assert.Equal("iban", json.GetProperty("matches")[0].GetProperty("generatorId").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenApi_and_health_are_exposed()
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/openapi/v1.json")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/health")).StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using GeradoresService;
|
|
||||||
using GeradoresService.DAL;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace UnitTest
|
|
||||||
{
|
|
||||||
public class DB
|
|
||||||
{
|
|
||||||
|
|
||||||
private readonly GeradoresContext _context;
|
|
||||||
public DB()
|
|
||||||
{
|
|
||||||
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.AddJsonFile("appsettings.json")
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Obter a connection string a partir do arquivo de configuração
|
|
||||||
var connectionString = configuration.GetConnectionString("GeradoresConnection");
|
|
||||||
|
|
||||||
// Configurar o DbContext para usar o InMemoryDatabase
|
|
||||||
var options = new DbContextOptionsBuilder<GeradoresContext>()
|
|
||||||
.UseSqlServer(connectionString)
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
_context = new GeradoresContext(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GenerateNIF()
|
|
||||||
{
|
|
||||||
var nif = new NIF(_context);
|
|
||||||
//var gerenatednif = nif.Generate("2");
|
|
||||||
//Assert.NotEmpty(gerenatednif);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
47
UnitTest/DetectTests.cs
Normal file
47
UnitTest/DetectTests.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using GeradoresService;
|
||||||
|
|
||||||
|
namespace UnitTest;
|
||||||
|
|
||||||
|
public class DetectTests
|
||||||
|
{
|
||||||
|
private static readonly GeneratorRegistry Registry = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_nif_first()
|
||||||
|
{
|
||||||
|
var matches = Registry.Detect("123456789");
|
||||||
|
Assert.NotEmpty(matches);
|
||||||
|
Assert.Equal("nif", matches[0].Generator.Id);
|
||||||
|
Assert.True(matches[0].Result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_iban()
|
||||||
|
{
|
||||||
|
var matches = Registry.Detect("PT50 0002 0123 1234 5678 9015 4");
|
||||||
|
Assert.Equal("iban", matches[0].Generator.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reports_near_miss_when_checksum_fails()
|
||||||
|
{
|
||||||
|
var matches = Registry.Detect("123456780");
|
||||||
|
Assert.Contains(matches, m => m.Generator.Id == "nif" && !m.Result.IsValid && m.Result.FormatMatch);
|
||||||
|
Assert.DoesNotContain(matches, m => m.Generator.Id == "nif" && m.Result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("isto não é nada")]
|
||||||
|
public void Nothing_detected(string value)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(Registry.Detect(value), m => m.Result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pessoa_is_never_detected()
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(Registry.Detect("João Silva"), m => m.Generator.Id == "pessoa");
|
||||||
|
}
|
||||||
|
}
|
||||||
107
UnitTest/GeneratorRoundTripTests.cs
Normal file
107
UnitTest/GeneratorRoundTripTests.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using GeradoresService;
|
||||||
|
using GeradoresService.Core;
|
||||||
|
|
||||||
|
namespace UnitTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Para cada gerador e cada valor de cada opção, os valores gerados têm de passar no próprio validador.
|
||||||
|
/// </summary>
|
||||||
|
public class GeneratorRoundTripTests
|
||||||
|
{
|
||||||
|
private static readonly GeneratorRegistry Registry = new();
|
||||||
|
|
||||||
|
public static TheoryData<string, string, string> AllOptionValues()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<string, string, string>();
|
||||||
|
foreach (var g in Registry.All)
|
||||||
|
{
|
||||||
|
if (g.Options.Count == 0)
|
||||||
|
data.Add(g.Id, "", "");
|
||||||
|
foreach (var o in g.Options)
|
||||||
|
foreach (var c in o.Choices)
|
||||||
|
data.Add(g.Id, o.Key, c.Value);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(AllOptionValues))]
|
||||||
|
public void Generated_values_are_valid(string generatorId, string optionKey, string optionValue)
|
||||||
|
{
|
||||||
|
var generator = Registry.Find(generatorId)!;
|
||||||
|
var options = new Dictionary<string, string?>();
|
||||||
|
if (optionKey != "")
|
||||||
|
options[optionKey] = optionValue;
|
||||||
|
|
||||||
|
var items = Registry.Generate(generator, 300, options, seed: 42);
|
||||||
|
|
||||||
|
Assert.All(items, item => Assert.False(string.IsNullOrWhiteSpace(item.Value)));
|
||||||
|
if (!generator.CanValidate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var result = generator.Validate(item.Value);
|
||||||
|
Assert.True(result.IsValid, $"{generatorId} gerou '{item.Value}' que não valida: {result.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pessoa_fields_are_all_valid()
|
||||||
|
{
|
||||||
|
var pessoa = Registry.Find("pessoa")!;
|
||||||
|
foreach (var item in Registry.Generate(pessoa, 200, new Dictionary<string, string?>(), seed: 7))
|
||||||
|
{
|
||||||
|
var f = item.Fields!;
|
||||||
|
Assert.True(Registry.Find("nif")!.Validate(f["NIF"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("niss")!.Validate(f["NISS"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("cc")!.Validate(f["Cartão de Cidadão"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("iban")!.Validate(f["IBAN"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("telefone")!.Validate(f["Telemóvel"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("email")!.Validate(f["Email"]).IsValid);
|
||||||
|
Assert.True(Registry.Find("codigo-postal")!.Validate(f["Código postal"]).IsValid);
|
||||||
|
|
||||||
|
var age = DateTime.UtcNow.Year - DateOnly.Parse(f["Data de nascimento"]).Year;
|
||||||
|
Assert.InRange(age, 18, 81);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Same_seed_gives_same_values()
|
||||||
|
{
|
||||||
|
var nif = Registry.Find("nif")!;
|
||||||
|
var a = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
|
||||||
|
var b = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
|
||||||
|
Assert.Equal(a.Select(x => x.Value), b.Select(x => x.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generated_values_are_not_all_equal()
|
||||||
|
{
|
||||||
|
var items = Registry.Generate(Registry.Find("nif")!, 50, new Dictionary<string, string?>());
|
||||||
|
Assert.True(items.Select(i => i.Value).Distinct().Count() > 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalid_option_value_throws()
|
||||||
|
{
|
||||||
|
var nif = Registry.Find("nif")!;
|
||||||
|
Assert.Throws<ArgumentException>(() =>
|
||||||
|
Registry.Generate(nif, 1, new Dictionary<string, string?> { ["tipo"] = "4" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(GeneratorRegistry.MaxBatch + 1)]
|
||||||
|
public void Count_out_of_range_throws(int count)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
Registry.Generate(Registry.Find("nif")!, count, new Dictionary<string, string?>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ids_are_unique()
|
||||||
|
{
|
||||||
|
Assert.Equal(Registry.All.Count, Registry.All.Select(g => g.Id).Distinct().Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using GeradoresService;
|
|
||||||
using GeradoresService.DAL;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace UnitTest
|
|
||||||
{
|
|
||||||
public class Geradores
|
|
||||||
{
|
|
||||||
private readonly GeradoresContext _context;
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GenerateCC()
|
|
||||||
{
|
|
||||||
var cc = CartaoCidadao.Generate();
|
|
||||||
Console.WriteLine(cc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
168
UnitTest/KnownValuesTests.cs
Normal file
168
UnitTest/KnownValuesTests.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using GeradoresService;
|
||||||
|
|
||||||
|
namespace UnitTest;
|
||||||
|
|
||||||
|
/// <summary>Valores conhecidos (exemplos oficiais / números de teste públicos).</summary>
|
||||||
|
public class KnownValuesTests
|
||||||
|
{
|
||||||
|
private static readonly GeneratorRegistry Registry = new();
|
||||||
|
|
||||||
|
private static void AssertValid(string id, string value) =>
|
||||||
|
Assert.True(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser válido.");
|
||||||
|
|
||||||
|
private static void AssertInvalid(string id, string value) =>
|
||||||
|
Assert.False(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser inválido.");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("123456789")]
|
||||||
|
[InlineData("PT 123 456 789")]
|
||||||
|
[InlineData("100000010")] // resto 0 → dígito 0 (falhava na versão antiga)
|
||||||
|
[InlineData("500000000")] // resto 1 → dígito 0
|
||||||
|
[InlineData("451234561")] // prefixo 45
|
||||||
|
public void Nif_valid(string nif) => AssertValid("nif", nif);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("123456780")]
|
||||||
|
[InlineData("100000011")]
|
||||||
|
[InlineData("12345678")]
|
||||||
|
[InlineData("412345678")] // prefixo 4 só é válido como 45
|
||||||
|
[InlineData("abcdefghi")]
|
||||||
|
public void Nif_invalid(string nif) => AssertInvalid("nif", nif);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nipc_rejects_individual_nif()
|
||||||
|
{
|
||||||
|
AssertValid("nipc", "500000000");
|
||||||
|
AssertInvalid("nipc", "123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nif_reports_expected_check_digit()
|
||||||
|
{
|
||||||
|
var result = Registry.Find("nif")!.Validate("123456780");
|
||||||
|
Assert.True(result.FormatMatch);
|
||||||
|
Assert.Contains("esperado 9", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("12345678902")]
|
||||||
|
[InlineData("20000000001")]
|
||||||
|
public void Niss_valid(string niss) => AssertValid("niss", niss);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("12345678903")]
|
||||||
|
[InlineData("32345678902")]
|
||||||
|
[InlineData("1234567890")]
|
||||||
|
public void Niss_invalid(string niss) => AssertInvalid("niss", niss);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("00000000 0 ZZ4")] // exemplo da especificação da AMA
|
||||||
|
[InlineData("000000000zz4")]
|
||||||
|
public void Cc_valid(string cc) => AssertValid("cc", cc);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("00000000 0 ZZ5")]
|
||||||
|
[InlineData("00000000 0 ZZ")]
|
||||||
|
public void Cc_invalid(string cc) => AssertInvalid("cc", cc);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("PT50000201231234567890154")] // exemplo do Banco de Portugal
|
||||||
|
[InlineData("PT50 0002 0123 1234 5678 9015 4")]
|
||||||
|
[InlineData("GB82 WEST 1234 5698 7654 32")]
|
||||||
|
[InlineData("DE89370400440532013000")]
|
||||||
|
public void Iban_valid(string iban) => AssertValid("iban", iban);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("PT50000201231234567890155")]
|
||||||
|
[InlineData("PT51000201231234567890154")]
|
||||||
|
[InlineData("PT5000020123123456789015")]
|
||||||
|
[InlineData("GB82WEST12345698765433")]
|
||||||
|
public void Iban_invalid(string iban) => AssertInvalid("iban", iban);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nib_valid() => AssertValid("nib", "0002 0123 12345678901 54");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("4111 1111 1111 1111")]
|
||||||
|
[InlineData("5555555555554444")]
|
||||||
|
[InlineData("2223003122003222")]
|
||||||
|
[InlineData("378282246310005")]
|
||||||
|
public void Card_valid(string card) => AssertValid("cartao-credito", card);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("4111111111111112")]
|
||||||
|
[InlineData("411111")]
|
||||||
|
public void Card_invalid(string card) => AssertInvalid("cartao-credito", card);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("4006381333931")]
|
||||||
|
[InlineData("96385074")]
|
||||||
|
public void Ean_valid(string ean) => AssertValid("ean", ean);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ean_invalid() => AssertInvalid("ean", "4006381333932");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("AA-00-AA")]
|
||||||
|
[InlineData("00 AB 00")]
|
||||||
|
[InlineData("12-34-XZ")]
|
||||||
|
[InlineData("ab0012")]
|
||||||
|
public void Matricula_valid(string m) => AssertValid("matricula", m);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("AK-00-AA")]
|
||||||
|
[InlineData("A1-00-AA")]
|
||||||
|
[InlineData("AA-00-AA-00")]
|
||||||
|
public void Matricula_invalid(string m) => AssertInvalid("matricula", m);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("+351 912 345 678")]
|
||||||
|
[InlineData("00351213456789")]
|
||||||
|
[InlineData("800 123 456")]
|
||||||
|
public void Telefone_valid(string t) => AssertValid("telefone", t);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("942 345 678")]
|
||||||
|
[InlineData("91234567")]
|
||||||
|
public void Telefone_invalid(string t) => AssertInvalid("telefone", t);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1000-001")]
|
||||||
|
[InlineData("4000 123")]
|
||||||
|
public void CodigoPostal_valid(string cp) => AssertValid("codigo-postal", cp);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0100-001")]
|
||||||
|
[InlineData("1000-01")]
|
||||||
|
public void CodigoPostal_invalid(string cp) => AssertInvalid("codigo-postal", cp);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("joao.silva@example.com")]
|
||||||
|
public void Email_valid(string e) => AssertValid("email", e);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("joao.silva")]
|
||||||
|
[InlineData("joao@silva")]
|
||||||
|
[InlineData("jo ao@example.com")]
|
||||||
|
public void Email_invalid(string e) => AssertInvalid("email", e);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multibanco_roundtrip_detects_tampering()
|
||||||
|
{
|
||||||
|
var mb = Registry.Find("multibanco")!;
|
||||||
|
var value = mb.Generate(new GeradoresService.Core.GeneratorContext(new Random(1), new Dictionary<string, string?>())).Value;
|
||||||
|
Assert.True(mb.Validate(value).IsValid);
|
||||||
|
|
||||||
|
var parts = value.Split(' ');
|
||||||
|
var amount = decimal.Parse(parts[^1], System.Globalization.CultureInfo.InvariantCulture) + 1;
|
||||||
|
var tampered = string.Join(' ', parts[..^1]) + " " + amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
Assert.False(mb.Validate(tampered).IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Uuid_versions()
|
||||||
|
{
|
||||||
|
Assert.Contains("versão 4", Registry.Find("uuid")!.Validate("f47ac10b-58cc-4372-a567-0e02b2c3d479").Message);
|
||||||
|
AssertInvalid("uuid", "not-a-uuid");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
UnitTest/README.md
Normal file
20
UnitTest/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# UnitTest
|
||||||
|
|
||||||
|
Testes xUnit da biblioteca [GeradoresService](../GeradoresService/README.md) e da API
|
||||||
|
[GeradoresWS](../GeradoresWS/README.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test UnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ficheiro | O que cobre |
|
||||||
|
|---|---|
|
||||||
|
| `GeneratorRoundTripTests.cs` | Para cada gerador e cada valor de cada opção, gera valores e confirma que o validador os aceita. Também: campos da pessoa fictícia válidos, `seed` reprodutível, ids únicos, opções e quantidades inválidas |
|
||||||
|
| `KnownValuesTests.cs` | Valores reais/conhecidos válidos e inválidos (NIF, NIPC, NISS, CC, IBAN, …) e o dígito de controlo esperado |
|
||||||
|
| `DetectTests.cs` | Validador livre: ordem dos resultados, quase-acertos (formato certo, controlo errado) e valores sem correspondência |
|
||||||
|
| `ApiTests.cs` | Testes de integração com `WebApplicationFactory<Program>`: listagem, geração em lote, 400/404, validação, detecção, OpenAPI e `/health` |
|
||||||
|
|
||||||
|
Os testes de ida e volta são gerados a partir do `GeneratorRegistry`, por isso um gerador novo fica coberto sem
|
||||||
|
escrever testes. Os valores conhecidos devem ser acrescentados à mão sempre que se corrige um bug num algoritmo.
|
||||||
|
|
||||||
|
O Jenkins corre `dotnet test UnitTest/UnitTest.csproj -c Release` antes de construir as imagens.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
@@ -10,34 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="appsettings.Development.json" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||||
<None Remove="appsettings.json" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="appsettings.Development.json">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
|
||||||
</Content>
|
|
||||||
<Content Include="appsettings.json">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.*" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@@ -45,6 +25,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\GeradoresService\GeradoresService.csproj" />
|
<ProjectReference Include="..\GeradoresService\GeradoresService.csproj" />
|
||||||
|
<ProjectReference Include="..\GeradoresWS\GeradoresWS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
services:
|
||||||
geradoresws:
|
geradoresws:
|
||||||
build:
|
build:
|
||||||
context: . # Caminho para a pasta do Dockerfile do backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: docker.io/shini89/geradoresws:latest
|
image: docker.io/shini89/geradoresws:latest
|
||||||
ports:
|
ports:
|
||||||
- "5050:8080"
|
- "5050:8080" # API directa (opcional)
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
environment:
|
|
||||||
- ASPNETCORE_ENVIRONMENT=Production # Variavel de ambiente para o backend
|
|
||||||
|
|
||||||
geradoresfe:
|
geradoresfe:
|
||||||
build:
|
build:
|
||||||
context: ./geradoresfe # Caminho para a pasta do Dockerfile do frontend
|
context: ./geradoresfe
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: docker.io/shini89/geradoresfe:latest
|
image: docker.io/shini89/geradoresfe:latest
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- geradoresws
|
- geradoresws
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:5050/
|
- API_UPSTREAM=http://geradoresws:8080
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=https://localhost:7266/
|
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
# ---------- BUILD ----------
|
# ---------- BUILD ----------
|
||||||
FROM node:20-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Variável de ambiente correta para Vite
|
|
||||||
ENV VITE_API_URL=http://localhost:5050/
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---------- SERVE ----------
|
# ---------- SERVE ----------
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Copia os ficheiros do build para a pasta do nginx
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# O nginx substitui ${API_UPSTREAM} no arranque (envsubst em /etc/nginx/templates).
|
||||||
|
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
# Configuração mínima do nginx (opcional: para SPA fallback)
|
# Endereço interno da API para onde /api, /docs e /openapi são encaminhados.
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
ENV API_UPSTREAM=http://geradoresws:8080
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|||||||
@@ -1,69 +1,63 @@
|
|||||||
# React + TypeScript + Vite
|
# geradoresfe
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Frontend React 19 + Vite + Tailwind CSS 4 + TypeScript. A UI não conhece os tipos de gerador: lê
|
||||||
|
`/api/v1/generators` e constrói as páginas e os formulários de opções a partir dos metadados da
|
||||||
|
[API](../GeradoresWS/README.md).
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Desenvolvimento
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
```bash
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
O Vite faz proxy de `/api`, `/docs` e `/openapi` para `http://localhost:5053`
|
||||||
|
(`dotnet run --project GeradoresWS --launch-profile http`). Para outro backend:
|
||||||
|
`API_PROXY_TARGET=http://host:porta npm run dev`.
|
||||||
|
|
||||||
```js
|
| Script | Descrição |
|
||||||
// eslint.config.js
|
|---|---|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
| `npm run dev` | Servidor de desenvolvimento |
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
| `npm run build` | Type-check (`tsc -b`) e build de produção em `dist/` |
|
||||||
|
| `npm run lint` | ESLint |
|
||||||
|
| `npm test` | Vitest |
|
||||||
|
| `npm run preview` | Serve o build localmente |
|
||||||
|
|
||||||
export default tseslint.config([
|
## Páginas
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
| Rota | Página |
|
||||||
files: ['**/*.{ts,tsx}'],
|
|---|---|
|
||||||
extends: [
|
| `/` | Lista de geradores por categoria |
|
||||||
// Other configs...
|
| `/gerador/:id` | Opções, geração em lote (até 1000), cópia e exportação CSV/JSON/TXT |
|
||||||
// Enable lint rules for React
|
| `/validar` | Validador livre: detecta o tipo e explica porque é (in)válido |
|
||||||
reactX.configs['recommended-typescript'],
|
| `/NIF`, `/NISS`, `/CC` | Redireccionam para as rotas novas |
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
## Estrutura
|
||||||
],
|
|
||||||
languageOptions: {
|
| Caminho | Conteúdo |
|
||||||
parserOptions: {
|
|---|---|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| `src/lib/api.ts` | Cliente da API e tipos |
|
||||||
tsconfigRootDir: import.meta.dirname,
|
| `src/lib/useGenerators.tsx` | Contexto que carrega a lista de geradores uma vez |
|
||||||
},
|
| `src/lib/export.ts` | Exportação CSV/JSON/TXT (testado em `export.test.ts`) |
|
||||||
// other options...
|
| `src/pages/` | Páginas das rotas |
|
||||||
},
|
| `src/components/` | Cartões, formulário de opções, validador, resultados |
|
||||||
},
|
| `src/components/ui/` | Componentes base (botão, input, select, badge, …) |
|
||||||
])
|
| `src/layout/` | Cabeçalho, rodapé e layout |
|
||||||
|
| `src/context/ThemeContext.tsx` | Modo claro/escuro |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Por omissão o cliente usa caminhos relativos (`/api/v1`). Em dev é o proxy do Vite; em produção é o nginx.
|
||||||
|
`VITE_API_URL` (em build time) permite apontar para outro host.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t geradoresfe .
|
||||||
|
docker run -p 3000:80 -e API_UPSTREAM=http://geradoresws:8080 geradoresfe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A imagem serve o build com nginx. O `nginx.conf.template` é processado com `envsubst` no arranque, e
|
||||||
|
`/api`, `/docs` e `/openapi` são encaminhados para `API_UPSTREAM` (por omissão `http://geradoresws:8080`).
|
||||||
|
A mesma imagem serve em qualquer ambiente sem rebuild. Os ficheiros em `/assets/` têm cache longa e o resto
|
||||||
|
cai no `index.html` (SPA).
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.83-alpha">
|
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.83-alpha">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<StartupCommand>set BROWSER=none&&npm start</StartupCommand>
|
<StartupCommand>npm run dev</StartupCommand>
|
||||||
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
|
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
|
||||||
<JavaScriptTestFramework>Jest</JavaScriptTestFramework>
|
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
|
||||||
<!-- Command to run on project build -->
|
<!-- Command to run on project build -->
|
||||||
<BuildCommand>
|
<BuildCommand>
|
||||||
</BuildCommand>
|
</BuildCommand>
|
||||||
<!-- Command to create an optimized build of the project that's ready for publishing -->
|
<!-- Command to create an optimized build of the project that's ready for publishing -->
|
||||||
<ProductionBuildCommand>npm run build</ProductionBuildCommand>
|
<ProductionBuildCommand>npm run build</ProductionBuildCommand>
|
||||||
<!-- Folder where production build objects will be placed -->
|
<!-- Folder where production build objects will be placed -->
|
||||||
<BuildOutputFolder>$(MSBuildProjectDirectory)\build</BuildOutputFolder>
|
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
</Project>
|
||||||
<None Include="..\.dockerignore">
|
|
||||||
<DependentUpon>$(DockerDefaultDockerfile)</DependentUpon>
|
|
||||||
<Link>.dockerignore</Link>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="src\Api\" />
|
|
||||||
<Folder Include="src\types\" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="pt-PT">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logotipo.png" />
|
<link rel="icon" type="image/png" href="/src/assets/logotipo32x32.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Factory Id</title>
|
<meta name="description" content="Gere e valide NIF, NISS, Cartão de Cidadão, IBAN, cartões, matrículas e outros dados portugueses para testes." />
|
||||||
|
<title>Factory Id — Geradores e validadores</title>
|
||||||
|
<script>
|
||||||
|
// Aplica o tema antes do primeiro render para evitar o "flash" de tema claro.
|
||||||
|
try {
|
||||||
|
const t = localStorage.getItem("theme");
|
||||||
|
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches))
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional: gzip
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain application/javascript text/css application/json image/svg+xml;
|
|
||||||
}
|
|
||||||
33
geradoresfe/nginx.conf.template
Normal file
33
geradoresfe/nginx.conf.template
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API no mesmo domínio: sem CORS e sem URL da API embutido no build.
|
||||||
|
location ~ ^/(api|docs|openapi)(/|$) {
|
||||||
|
proxy_pass ${API_UPSTREAM};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ficheiros com hash no nome: cache longa.
|
||||||
|
# (add_header num location anula os do server, por isso os cabeçalhos de segurança repetem-se.)
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
}
|
||||||
4573
geradoresfe/package-lock.json
generated
Normal file
4573
geradoresfe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "geradoresfe",
|
"name": "geradoresfe",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@heroicons/react": "^2.2.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
|
||||||
"axios": "^1.10.0",
|
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-router": "^7.6.3",
|
"react-router": "^7.6.3",
|
||||||
"react-router-dom": "^7.6.3",
|
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"swagger-typescript-api": "^13.2.7",
|
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
require('@tailwindcss/postcss'),
|
|
||||||
require('autoprefixer'),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import type { AxiosResponse } from 'axios';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
class GeradorService {
|
|
||||||
static async GenerateNIF(type: string | null): Promise<any[]> {
|
|
||||||
try {
|
|
||||||
debugger;
|
|
||||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateNIF',
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
type: type
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error : any) {
|
|
||||||
|
|
||||||
toast.error('Error fetching NIF:' + error.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async ValidateNIF(nif: string | null): Promise<any[]> {
|
|
||||||
try {
|
|
||||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/ValidateNIF',
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
nif: nif
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Error fetching NIF:' + error);
|
|
||||||
|
|
||||||
console.error('Error fetching NIF:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async GenerateNISS(): Promise<any[]> {
|
|
||||||
try {
|
|
||||||
|
|
||||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateNISS');
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching NIF:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async GenerateCC(): Promise<any[]> {
|
|
||||||
try {
|
|
||||||
|
|
||||||
const response: AxiosResponse = await axios.get(API_URL + 'Generate/GenerateCC');
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching NIF:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GeradorService;
|
|
||||||
@@ -1,34 +1,41 @@
|
|||||||
import { Routes, Route, BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router";
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from "react-hot-toast";
|
||||||
import Home from './pages/Home';
|
import { Layout } from "./layout/Layout";
|
||||||
import { Layout } from './layout/Layout';
|
import { useTheme } from "./context/ThemeContext";
|
||||||
import { useTheme } from './context/ThemeContext';
|
import { GeneratorsProvider } from "./lib/useGenerators";
|
||||||
import NIF from './pages/NIF';
|
import HomePage from "./pages/HomePage";
|
||||||
import CC from './pages/CC';
|
import GeneratorPage from "./pages/GeneratorPage";
|
||||||
import NISS from './pages/NISS';
|
import ValidatorPage from "./pages/ValidatorPage";
|
||||||
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
|
|
||||||
function App() {
|
export default function App() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<BrowserRouter>
|
||||||
<Layout>
|
<GeneratorsProvider>
|
||||||
<Routes>
|
<Layout>
|
||||||
<Route path="/" element={<Home />} />
|
<Routes>
|
||||||
<Route path="/NIF" element={<NIF />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/NISS" element={<NISS />} />
|
<Route path="/gerador/:id" element={<GeneratorPage />} />
|
||||||
<Route path="/CC" element={<CC />} />
|
<Route path="/validar" element={<ValidatorPage />} />
|
||||||
</Routes>
|
{/* Rotas antigas */}
|
||||||
<Toaster position="top-right"
|
<Route path="/NIF" element={<Navigate to="/gerador/nif" replace />} />
|
||||||
|
<Route path="/NISS" element={<Navigate to="/gerador/niss" replace />} />
|
||||||
|
<Route path="/CC" element={<Navigate to="/gerador/cc" replace />} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
background: theme === 'dark' ? '#1f2937' : '#fff',
|
background: theme === "dark" ? "#27272a" : "#fff",
|
||||||
color: theme === 'dark' ? '#fff' : '#000',
|
color: theme === "dark" ? "#fff" : "#18181b",
|
||||||
},
|
},
|
||||||
}} />
|
}}
|
||||||
</Layout>
|
/>
|
||||||
</Router>
|
</GeneratorsProvider>
|
||||||
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
14
geradoresfe/src/components/ChecksumBadge.tsx
Normal file
14
geradoresfe/src/components/ChecksumBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ShieldCheckIcon } from "lucide-react";
|
||||||
|
import type { Generator } from "../lib/api";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
|
|
||||||
|
export function ChecksumBadge({ generator }: { generator: Generator }) {
|
||||||
|
if (!generator.canValidate) return null;
|
||||||
|
return generator.hasChecksum ? (
|
||||||
|
<Badge tone="green" className="shrink-0">
|
||||||
|
<ShieldCheckIcon className="h-3 w-3" /> Controlo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="shrink-0">Formato</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
geradoresfe/src/components/GeneratorCard.tsx
Normal file
57
geradoresfe/src/components/GeneratorCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { api, defaultOptions, type Generator } from "../lib/api";
|
||||||
|
import { OptionsForm } from "./OptionsForm";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { ResultDisplay } from "./ui/ResultDisplay";
|
||||||
|
import { ChecksumBadge } from "./ChecksumBadge";
|
||||||
|
|
||||||
|
/** Cartão de geração rápida (1 valor) usado na página inicial. */
|
||||||
|
export function GeneratorCard({ generator }: { generator: Generator }) {
|
||||||
|
const [options, setOptions] = useState(() => defaultOptions(generator));
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [item] = await api.generate(generator.id, options);
|
||||||
|
setValue(item.value);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<header>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-bold text-blue-700 dark:text-blue-400">{generator.name}</h3>
|
||||||
|
<ChecksumBadge generator={generator} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">{generator.description}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<OptionsForm generator={generator} values={options} onChange={(k, v) => setOptions((o) => ({ ...o, [k]: v }))} />
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-3">
|
||||||
|
<ResultDisplay value={value} label={generator.name} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={generate} loading={loading} icon={<RefreshCwIcon className="h-4 w-4" />} className="flex-1">
|
||||||
|
Gerar
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
to={`/gerador/${generator.id}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Em lote <ArrowRightIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// src/components/Geradores/GeradorCard.tsx
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { RefreshCwIcon, CopyIcon } from "lucide-react";
|
|
||||||
import { Button } from "../ui/Button";
|
|
||||||
import { Input } from "../ui/Input";
|
|
||||||
import { Select } from "../ui/Select";
|
|
||||||
import { ResultDisplay } from "../ui/ResultDisplay";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
|
|
||||||
export type Parametro =
|
|
||||||
| { nome: string; tipo: "text" | "number" | "password"; placeholder?: string; default?: string }
|
|
||||||
| { nome: string; tipo: "select"; options: { label: string; value: string }[]; default?: string };
|
|
||||||
|
|
||||||
export type GeradorCardProps = {
|
|
||||||
readonly titulo: string;
|
|
||||||
readonly descricao?: string;
|
|
||||||
readonly parametros?: Parametro[];
|
|
||||||
readonly onGerar: (parametros?: Record<string, string>) => string | Promise<string>;
|
|
||||||
readonly onValidar?: (valor: string) => boolean | Promise<boolean>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function GeradorCard({
|
|
||||||
titulo,
|
|
||||||
descricao,
|
|
||||||
parametros = [],
|
|
||||||
onGerar,
|
|
||||||
onValidar,
|
|
||||||
}: GeradorCardProps) {
|
|
||||||
// Inicializa paramInputs com defaults vindos de `parametros`
|
|
||||||
const initialParamInputs = parametros.reduce<Record<string, string>>((acc, p) => {
|
|
||||||
debugger;
|
|
||||||
if (p.default) {
|
|
||||||
acc[p.nome] = p.default;
|
|
||||||
} else {
|
|
||||||
acc[p.nome] = "";
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const [resultado, setResultado] = useState("");
|
|
||||||
const [paramInputs, setParamInputs] = useState<Record<string, string>>(initialParamInputs);
|
|
||||||
const [validado, setValidado] = useState<boolean | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleInputChange = useCallback((nome: string, valor: string) => {
|
|
||||||
setParamInputs((prev) => ({ ...prev, [nome]: valor }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleGerar = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setValidado(null);
|
|
||||||
try {
|
|
||||||
const gerado = await onGerar(paramInputs);
|
|
||||||
setResultado(typeof gerado === "string" ? gerado : String(gerado ?? ""));
|
|
||||||
toast.success(`${titulo} gerado com sucesso.`, { duration: 2000 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao gerar:", err);
|
|
||||||
toast.error("Falha ao gerar. Tente novamente.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [onGerar, paramInputs, titulo]);
|
|
||||||
|
|
||||||
const handleValidar = useCallback(async () => {
|
|
||||||
if (!onValidar) return;
|
|
||||||
if (!resultado) {
|
|
||||||
toast.error("Nenhum valor para validar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const isValid = await onValidar(resultado);
|
|
||||||
setValidado(Boolean(isValid));
|
|
||||||
if (isValid) {
|
|
||||||
toast.success("Válido", { duration: 1500 });
|
|
||||||
} else {
|
|
||||||
toast.error("Inválido", { duration: 1500 });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao validar:", err);
|
|
||||||
toast.error("Falha na validação. Tente novamente.");
|
|
||||||
setValidado(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [onValidar, resultado]);
|
|
||||||
|
|
||||||
const handleCopiar = useCallback(async () => {
|
|
||||||
if (!resultado) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(resultado);
|
|
||||||
toast.success(`${titulo} copiado!`, { duration: 2000 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao copiar:", err);
|
|
||||||
toast.error("Não foi possível copiar para a área de transferência.");
|
|
||||||
}
|
|
||||||
}, [resultado, titulo]);
|
|
||||||
|
|
||||||
const handleResultadoChange = useCallback((valor: string) => {
|
|
||||||
setResultado(valor);
|
|
||||||
setValidado(null); // reset ao editar
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-2xl border border-gray-200 bg-white dark:bg-zinc-900 p-6 shadow-lg hover:shadow-xl transition-shadow duration-300"
|
|
||||||
aria-busy={loading}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-blue-700 dark:text-blue-400">{titulo}</h3>
|
|
||||||
{descricao && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{descricao}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{parametros.length > 0 && (
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
{parametros.map((param) => {
|
|
||||||
if (param.tipo === "select") {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
key={param.nome}
|
|
||||||
label={param.nome}
|
|
||||||
options={param.options}
|
|
||||||
value={paramInputs[param.nome] ?? ""}
|
|
||||||
onChange={(val) => handleInputChange(param.nome, val)}
|
|
||||||
placeholder={`Selecionar ${param.nome}`}
|
|
||||||
aria-label={param.nome}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
key={param.nome}
|
|
||||||
type={param.tipo}
|
|
||||||
placeholder={param.placeholder || param.nome}
|
|
||||||
value={paramInputs[param.nome] ?? ""}
|
|
||||||
onChange={(e) => handleInputChange(param.nome, e.target.value)}
|
|
||||||
aria-label={param.nome}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-6 flex items-center gap-2 relative">
|
|
||||||
<ResultDisplay
|
|
||||||
value={resultado}
|
|
||||||
validated={validado}
|
|
||||||
onChange={handleResultadoChange}
|
|
||||||
aria-label={`${titulo} resultado`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{resultado && (
|
|
||||||
<Button
|
|
||||||
onClick={handleCopiar}
|
|
||||||
icon={<CopyIcon className="w-4 h-4" />}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs h-auto"
|
|
||||||
title="Copiar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full gap-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleGerar}
|
|
||||||
icon={<RefreshCwIcon className="w-4 h-4" />}
|
|
||||||
variant="primary"
|
|
||||||
className="flex-1"
|
|
||||||
title={`Gerar ${titulo}`}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "A gerar..." : "Gerar"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onValidar && (
|
|
||||||
<Button
|
|
||||||
onClick={handleValidar}
|
|
||||||
variant={validado === true ? "primary" : "danger"}
|
|
||||||
className="flex-1"
|
|
||||||
title={`Validar ${titulo}`}
|
|
||||||
disabled={loading || !resultado}
|
|
||||||
>
|
|
||||||
{loading ? "Verificando..." : "Validar"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import GeradorService from "../../../Api/GeradorApi.tsx";
|
|
||||||
import GeradorCard from "../GeradorCard.tsx";
|
|
||||||
|
|
||||||
const GenerateCC = () => {
|
|
||||||
|
|
||||||
const handleGenerateCC = async () => {
|
|
||||||
const cc = await GeradorService.GenerateCC()
|
|
||||||
return cc;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateCC = async (valor: string) => {
|
|
||||||
// Lógica real de validação
|
|
||||||
//const bol = await GeradorService.ValidateCC(valor);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto p-6">
|
|
||||||
<GeradorCard
|
|
||||||
titulo="Cartão Cidadão"
|
|
||||||
onGerar={handleGenerateCC}
|
|
||||||
onValidar={handleValidateCC}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GenerateCC;
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import GeradorCard, { type Parametro } from "../GeradorCard";
|
|
||||||
import { EnumToOptions, getEnumKeyByValue } from "../../../library/utils";
|
|
||||||
import { NIFType } from "../../../service/api";
|
|
||||||
import GeradorService from "../../../Api/GeradorApi";
|
|
||||||
|
|
||||||
const NifTypes = EnumToOptions(NIFType).map((opt) => ({
|
|
||||||
label: opt.label,
|
|
||||||
value: opt.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const GenerateNIF = () => {
|
|
||||||
const handleGenerateNIF = async (params?: Record<string, string>) => {
|
|
||||||
const tipoSelecionado = params?.["Tipo de NIF"] ?? String(NIFType.PessoaSingular2);
|
|
||||||
const nif = String(await GeradorService.GenerateNIF(tipoSelecionado));
|
|
||||||
return nif;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateNIF = async (valor: string) => {
|
|
||||||
const bol = Boolean(await GeradorService.ValidateNIF(valor));
|
|
||||||
return bol;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parametros: Parametro[] = [
|
|
||||||
{
|
|
||||||
nome: "Tipo de NIF",
|
|
||||||
tipo: "select",
|
|
||||||
options: NifTypes,
|
|
||||||
default: getEnumKeyByValue(NIFType, NIFType.PessoaSingular2)
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto p-6">
|
|
||||||
<GeradorCard
|
|
||||||
titulo="NIF"
|
|
||||||
parametros={parametros}
|
|
||||||
onGerar={handleGenerateNIF}
|
|
||||||
onValidar={handleValidateNIF}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GenerateNIF;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import GeradorService from "../../../Api/GeradorApi";
|
|
||||||
import GeradorCard from "../GeradorCard.tsx";
|
|
||||||
|
|
||||||
const GenerateNISS = () => {
|
|
||||||
|
|
||||||
const handleGenerateNISS = async () => {
|
|
||||||
const niss = await GeradorService.GenerateNISS()
|
|
||||||
return niss;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateNISS = async (valor: string) => {
|
|
||||||
// Lógica real de validação
|
|
||||||
const bol = await GeradorService.ValidateNISS(valor);
|
|
||||||
return bol;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto p-6">
|
|
||||||
<GeradorCard
|
|
||||||
titulo="NISS"
|
|
||||||
onGerar={handleGenerateNISS}
|
|
||||||
onValidar={handleValidateNISS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GenerateNISS;
|
|
||||||
26
geradoresfe/src/components/LoadState.tsx
Normal file
26
geradoresfe/src/components/LoadState.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Loader2, ServerCrashIcon } from "lucide-react";
|
||||||
|
import { useGenerators } from "../lib/useGenerators";
|
||||||
|
|
||||||
|
/** Mostra loading/erro enquanto a lista de geradores não está disponível. */
|
||||||
|
export function LoadState({ children }: { children: ReactNode }) {
|
||||||
|
const { loading, error } = useGenerators();
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16 text-gray-500">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" aria-label="A carregar" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-12 flex max-w-md flex-col items-center gap-2 rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200">
|
||||||
|
<ServerCrashIcon className="h-8 w-8" />
|
||||||
|
<p className="font-semibold">Não foi possível carregar os geradores.</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// components/Logo.tsx
|
|
||||||
import logo from '../assets/Logotipo.png';
|
|
||||||
|
|
||||||
export const Logo = () => (
|
|
||||||
<img src={logo} alt="FactoryId logo" className="h-8 w-8" />
|
|
||||||
);
|
|
||||||
25
geradoresfe/src/components/OptionsForm.tsx
Normal file
25
geradoresfe/src/components/OptionsForm.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Generator } from "../lib/api";
|
||||||
|
import { Select } from "./ui/Select";
|
||||||
|
|
||||||
|
type OptionsFormProps = {
|
||||||
|
generator: Generator;
|
||||||
|
values: Record<string, string>;
|
||||||
|
onChange: (key: string, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OptionsForm({ generator, values, onChange }: OptionsFormProps) {
|
||||||
|
if (generator.options.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{generator.options.map((option) => (
|
||||||
|
<Select
|
||||||
|
key={option.key}
|
||||||
|
label={option.label}
|
||||||
|
options={option.choices}
|
||||||
|
value={values[option.key] ?? option.defaultValue}
|
||||||
|
onChange={(v) => onChange(option.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
geradoresfe/src/components/ValidationResultView.tsx
Normal file
39
geradoresfe/src/components/ValidationResultView.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { AlertTriangleIcon, CheckCircle2Icon, XCircleIcon } from "lucide-react";
|
||||||
|
import type { ValidationResult } from "../lib/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
result: ValidationResult;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ValidationResultView({ result, title }: Props) {
|
||||||
|
const tone = result.isValid
|
||||||
|
? "border-green-300 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950/50 dark:text-green-200"
|
||||||
|
: result.formatMatch
|
||||||
|
? "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"
|
||||||
|
: "border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/50 dark:text-red-200";
|
||||||
|
|
||||||
|
const Icon = result.isValid ? CheckCircle2Icon : result.formatMatch ? AlertTriangleIcon : XCircleIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${tone}`} role="status">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title && <p className="font-semibold">{title}</p>}
|
||||||
|
<p className={title ? "text-sm" : "font-medium"}>{result.message}</p>
|
||||||
|
{result.normalized && (
|
||||||
|
<p className="mt-1 break-all font-mono text-sm opacity-80">{result.normalized}</p>
|
||||||
|
)}
|
||||||
|
{result.details && result.details.length > 0 && (
|
||||||
|
<ul className="mt-2 list-inside list-disc space-y-0.5 text-sm opacity-80">
|
||||||
|
{result.details.map((d) => (
|
||||||
|
<li key={d}>{d}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
geradoresfe/src/components/Validator.tsx
Normal file
84
geradoresfe/src/components/Validator.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { SearchCheckIcon } from "lucide-react";
|
||||||
|
import { api, type DetectMatch } from "../lib/api";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { Input } from "./ui/Input";
|
||||||
|
import { ValidationResultView } from "./ValidationResultView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validador livre: o utilizador cola um valor qualquer e a API identifica o tipo
|
||||||
|
* (NIF, IBAN, CC, …) e se é válido, incluindo "quase" correspondências com o dígito errado.
|
||||||
|
*/
|
||||||
|
export function Validator({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [matches, setMatches] = useState<DetectMatch[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!value.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setMatches(await api.detect(value));
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
setMatches(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const valid = matches?.filter((m) => m.result.isValid) ?? [];
|
||||||
|
const nearMisses = matches?.filter((m) => !m.result.isValid) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setMatches(null);
|
||||||
|
}}
|
||||||
|
placeholder="Cole um NIF, NISS, CC, IBAN, cartão, matrícula, EAN…"
|
||||||
|
aria-label="Valor a validar"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="py-3 font-mono text-base"
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={loading} icon={<SearchCheckIcon className="h-4 w-4" />} className="py-3 sm:w-40">
|
||||||
|
Validar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{matches && matches.length === 0 && (
|
||||||
|
<ValidationResultView
|
||||||
|
result={{ isValid: false, formatMatch: false, message: "Não foi reconhecido como nenhum dos tipos suportados." }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{valid.map((m) => (
|
||||||
|
<ValidationResultView key={m.generatorId} title={m.generatorName} result={m.result} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{valid.length === 0 && nearMisses.map((m) => (
|
||||||
|
<ValidationResultView key={m.generatorId} title={`Parece um ${m.generatorName}, mas…`} result={m.result} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{valid.length > 0 && nearMisses.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Também tem o formato de: {nearMisses.map((m) => (
|
||||||
|
<Link key={m.generatorId} to={`/gerador/${m.generatorId}`} className="mr-2 underline">
|
||||||
|
{m.generatorName}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
(mas não é válido como tal).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
geradoresfe/src/components/ui/Badge.tsx
Normal file
18
geradoresfe/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
const tones = {
|
||||||
|
neutral: "bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-gray-300",
|
||||||
|
blue: "bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
|
||||||
|
green: "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300",
|
||||||
|
red: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
|
||||||
|
amber: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ tone = "neutral", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={twMerge("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium", tones[tone], className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,36 @@
|
|||||||
// components/ui/Button.tsx
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
loadingText?: string;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
rounded?: boolean | "md" | "lg" | "xl" | "2xl" | "full";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
const variants = {
|
||||||
icon,
|
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500 dark:hover:bg-blue-500",
|
||||||
variant = "primary",
|
|
||||||
children,
|
|
||||||
className = "",
|
|
||||||
type = "button",
|
|
||||||
loading = false,
|
|
||||||
loadingText,
|
|
||||||
disabled,
|
|
||||||
fullWidth = false,
|
|
||||||
rounded = "2xl",
|
|
||||||
...rest
|
|
||||||
}: ButtonProps) {
|
|
||||||
const base = twMerge(
|
|
||||||
"inline-flex items-center justify-center gap-2 px-5 py-2.5 font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md",
|
|
||||||
fullWidth && "w-full",
|
|
||||||
typeof rounded === "string" ? `rounded-${rounded}` : rounded === true ? "rounded-md" : "rounded-2xl"
|
|
||||||
);
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
primary:
|
|
||||||
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-500",
|
|
||||||
secondary:
|
secondary:
|
||||||
"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 focus:ring-zinc-400 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600",
|
"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 focus-visible:ring-zinc-400 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600",
|
||||||
ghost:
|
ghost: "bg-transparent text-zinc-700 hover:bg-zinc-100 focus-visible:ring-zinc-300 dark:text-zinc-200 dark:hover:bg-zinc-800",
|
||||||
"bg-transparent text-zinc-700 hover:bg-zinc-100 focus:ring-zinc-300 dark:text-zinc-200 dark:hover:bg-zinc-800",
|
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 dark:hover:bg-red-500",
|
||||||
danger:
|
};
|
||||||
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-600 dark:hover:bg-red-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
export function Button({ icon, variant = "primary", children, className, type = "button", loading = false, disabled, ...rest }: ButtonProps) {
|
||||||
<button
|
return (
|
||||||
type={type}
|
<button
|
||||||
className={twMerge(base, variants[variant], className)}
|
type={type}
|
||||||
disabled={disabled || loading}
|
className={twMerge(
|
||||||
aria-busy={loading}
|
"inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-offset-zinc-900",
|
||||||
{...rest}
|
variants[variant],
|
||||||
>
|
className,
|
||||||
{loading ? (
|
)}
|
||||||
<>
|
disabled={disabled || loading}
|
||||||
<Loader2 className="animate-spin w-4 h-4" />
|
aria-busy={loading}
|
||||||
{loadingText && <span>{loadingText}</span>}
|
{...rest}
|
||||||
</>
|
>
|
||||||
) : (
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
|
||||||
<>
|
{children}
|
||||||
{icon && <span className="flex items-center">{icon}</span>}
|
</button>
|
||||||
{children}
|
);
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
export function ButtonGroup({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap w-full gap-x-2">{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
|
import { MoonIcon, SunIcon } from "lucide-react";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
export function DarkModeToggle() {
|
export function DarkModeToggle() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
console.log("Toggle clicado, tema antes:", theme);
|
|
||||||
toggleTheme();
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Render DarkModeToggle, theme =", theme);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
type="button"
|
||||||
className="p-2 text-gray-700 hover:text-blue-500 dark:text-gray-300"
|
onClick={toggleTheme}
|
||||||
aria-label="Alternar modo escuro"
|
className="rounded-md p-2 text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
aria-label={theme === "dark" ? "Mudar para tema claro" : "Mudar para tema escuro"}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
||||||
<SunIcon className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<MoonIcon className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React from "react";
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
export function Input({ className = "", ...props }: InputProps) {
|
export function Input({ className, ...props }: InputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={
|
className={twMerge(
|
||||||
"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 " +
|
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-gray-100",
|
||||||
className
|
className,
|
||||||
}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import { CheckIcon, XIcon } from "lucide-react";
|
import { CopyIcon } from "lucide-react";
|
||||||
|
import { copyToClipboard } from "../../lib/clipboard";
|
||||||
|
|
||||||
type ResultDisplayProps = {
|
type ResultDisplayProps = {
|
||||||
value: string;
|
value: string;
|
||||||
validated: boolean | null;
|
label: string;
|
||||||
onChange?: (value: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResultDisplay({ value, validated, onChange }: ResultDisplayProps) {
|
export function ResultDisplay({ value, label }: ResultDisplayProps) {
|
||||||
const baseClasses = "flex items-center gap-2 px-3 py-2 border rounded-md text-sm w-full";
|
|
||||||
const colors =
|
|
||||||
validated === true
|
|
||||||
? "border-green-500 text-green-700 bg-green-50 dark:text-green-400 dark:bg-green-950 dark:border-green-700"
|
|
||||||
: validated === false
|
|
||||||
? "border-red-500 text-red-700 bg-red-50 dark:text-red-400 dark:bg-red-950 dark:border-red-700"
|
|
||||||
: "border-gray-300 text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-zinc-800 dark:border-gray-600";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={baseClasses + " " + colors}>
|
<div className="flex items-center gap-2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 dark:border-zinc-600 dark:bg-zinc-800">
|
||||||
<input
|
<output aria-label={`${label} gerado`} className="min-h-5 flex-1 truncate font-mono text-sm text-gray-900 dark:text-gray-100">
|
||||||
type="text"
|
{value || <span className="text-gray-400">—</span>}
|
||||||
readOnly={!onChange}
|
</output>
|
||||||
value={value}
|
<button
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
type="button"
|
||||||
className="flex-1 bg-transparent outline-none font-mono truncate"
|
onClick={() => copyToClipboard(value, label)}
|
||||||
/>
|
disabled={!value}
|
||||||
{validated === true && <CheckIcon className="w-5 h-5" />}
|
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-blue-600 disabled:opacity-30 dark:hover:bg-zinc-700"
|
||||||
{validated === false && <XIcon className="w-5 h-5" />}
|
title="Copiar"
|
||||||
|
aria-label="Copiar"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
// components/ui/Select.tsx
|
import { useId } from "react";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Option {
|
type Option = { label: string; value: string };
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectProps {
|
type SelectProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Select = ({ label, options, value, onChange, placeholder }: SelectProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{label && <label className="mb-1 font-medium text-gray-700 dark:text-gray-300">{label}</label>}
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="border border-gray-300 rounded-md px-3 py-2 bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
|
||||||
>
|
|
||||||
<option value="" disabled>{placeholder || "Selecionar..."}</option>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function Select({ label, options, value, onChange }: SelectProps) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
|
|
||||||
type SidebarContextType = {
|
|
||||||
isExpanded: boolean;
|
|
||||||
isMobileOpen: boolean;
|
|
||||||
isHovered: boolean;
|
|
||||||
activeItem: string | null;
|
|
||||||
openSubmenu: string | null;
|
|
||||||
toggleSidebar: () => void;
|
|
||||||
toggleMobileSidebar: () => void;
|
|
||||||
setIsHovered: (isHovered: boolean) => void;
|
|
||||||
setActiveItem: (item: string | null) => void;
|
|
||||||
toggleSubmenu: (item: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const useSidebar = () => {
|
|
||||||
const context = useContext(SidebarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [activeItem, setActiveItem] = useState<string | null>(null);
|
|
||||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
const mobile = window.innerWidth < 768;
|
|
||||||
setIsMobile(mobile);
|
|
||||||
if (!mobile) {
|
|
||||||
setIsMobileOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize();
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
setIsExpanded((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMobileSidebar = () => {
|
|
||||||
setIsMobileOpen((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSubmenu = (item: string) => {
|
|
||||||
setOpenSubmenu((prev) => (prev === item ? null : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarContext.Provider
|
|
||||||
value={{
|
|
||||||
isExpanded: isMobile ? false : isExpanded,
|
|
||||||
isMobileOpen,
|
|
||||||
isHovered,
|
|
||||||
activeItem,
|
|
||||||
openSubmenu,
|
|
||||||
toggleSidebar,
|
|
||||||
toggleMobileSidebar,
|
|
||||||
setIsHovered,
|
|
||||||
setActiveItem,
|
|
||||||
toggleSubmenu,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
@@ -9,46 +9,36 @@ type ThemeContextType = {
|
|||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
function initialTheme(): Theme {
|
||||||
const [theme, setTheme] = useState<Theme | null>(null); // null = tema ainda não carregado
|
try {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage indisponível (modo privado, etc.)
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedTheme = localStorage.getItem("theme") as Theme | null;
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
try {
|
||||||
const initialTheme = storedTheme ?? (prefersDark ? "dark" : "light");
|
localStorage.setItem("theme", theme);
|
||||||
setTheme(initialTheme);
|
} catch {
|
||||||
}, []);
|
// ignorar
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (theme === null) return; // ainda a carregar
|
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
root.classList.add(theme);
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||||
setTheme((prev) => {
|
|
||||||
const nextTheme = prev === "dark" ? "light" : "dark";
|
|
||||||
console.log("ToggleTheme:", prev, "->", nextTheme);
|
|
||||||
return nextTheme;
|
|
||||||
});
|
|
||||||
//setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enquanto tema não carregou, não renderiza nada
|
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
|
||||||
if (theme === null) return null;
|
}
|
||||||
|
|
||||||
return (
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
export function useTheme(): ThemeContextType {
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTheme = (): ThemeContextType => {
|
|
||||||
const context = useContext(ThemeContext);
|
const context = useContext(ThemeContext);
|
||||||
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
return context;
|
return context;
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
const useGoBack = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
if (window.history.state && window.history.state.idx > 0) {
|
|
||||||
navigate(-1); // Go back to the previous page
|
|
||||||
} else {
|
|
||||||
navigate("/"); // Redirect to home if no history exists
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return goBack;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useGoBack;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
export const useModal = (initialState: boolean = false) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(initialState);
|
|
||||||
|
|
||||||
const openModal = useCallback(() => setIsOpen(true), []);
|
|
||||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
|
||||||
const toggleModal = useCallback(() => setIsOpen((prev) => !prev), []);
|
|
||||||
|
|
||||||
return { isOpen, openModal, closeModal, toggleModal };
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,67 +1,38 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { docsUrl } from "../lib/api";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gray-100 dark:bg-zinc-900 text-gray-700 dark:text-gray-300 mt-20">
|
<footer className="mt-20 border-t border-gray-200 bg-gray-50 text-gray-700 dark:border-zinc-800 dark:bg-zinc-950 dark:text-gray-300">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div>
|
||||||
{/* Marca e descrição */}
|
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Factory Id</h2>
|
||||||
<div>
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">FactoryiD</h2>
|
Geradores e validadores de dados portugueses para testes de software.
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
</p>
|
||||||
Geradores inteligentes para profissionais modernos. Soluções simples, rápidas e acessíveis.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navegação principal */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Navegação</h3>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
<li>
|
|
||||||
<Link to="/" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
|
||||||
Início
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/home" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
|
||||||
Geradores
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/contacto" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
|
||||||
Contacto
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Outros links */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Outros</h3>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
<li>
|
|
||||||
<Link to="/termos" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
|
||||||
Termos de Utilização
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/privacidade" className="hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
|
|
||||||
Política de Privacidade
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separador */}
|
<div>
|
||||||
<hr className="my-8 border-gray-300 dark:border-gray-700" />
|
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Navegação</h3>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
{/* Copyright */}
|
<li><Link to="/" className="hover:text-blue-600 dark:hover:text-blue-400">Geradores</Link></li>
|
||||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400">
|
<li><Link to="/validar" className="hover:text-blue-600 dark:hover:text-blue-400">Validador</Link></li>
|
||||||
© {year} FactoryiD. Todos os direitos reservados.
|
<li><a href={docsUrl} target="_blank" rel="noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400">Documentação da API</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Aviso</h3>
|
||||||
|
<p>
|
||||||
|
Todos os dados são fictícios e gerados aleatoriamente. Destinam-se exclusivamente a testes;
|
||||||
|
qualquer coincidência com dados reais é acidental.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-200 py-6 text-center text-sm text-gray-500 dark:border-zinc-800 dark:text-gray-400">
|
||||||
|
© {year} Factory Id
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,172 +1,105 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, NavLink, useLocation } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import { Disclosure, Menu, Transition } from "@headlessui/react";
|
import {
|
||||||
import { Bars3Icon, XMarkIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
Disclosure, DisclosureButton, DisclosurePanel, Menu, MenuButton, MenuItem, MenuItems,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
|
||||||
import Logo from "../assets/logotipo32x32.png";
|
import Logo from "../assets/logotipo32x32.png";
|
||||||
import { DarkModeToggle } from "../components/ui/DarkModeToggle";
|
import { DarkModeToggle } from "../components/ui/DarkModeToggle";
|
||||||
|
import { useGenerators } from "../lib/useGenerators";
|
||||||
|
import { docsUrl } from "../lib/api";
|
||||||
|
|
||||||
const navItems = [
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
{ name: "Início", path: "/" },
|
`rounded-md px-3 py-2 text-sm font-medium transition-colors ${isActive
|
||||||
{
|
? "text-blue-600 dark:text-blue-400"
|
||||||
name: "Geradores",
|
: "text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400"}`;
|
||||||
subItems: [
|
|
||||||
{ name: "Gerador de NIFs", path: "/NIF", },
|
|
||||||
{ name: "Gerador de NISS", path: "/NISS" },
|
|
||||||
{ name: "Gerador de CC", path: "/CC" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ name: "Contacto", path: "/contacto" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Header() {
|
export default function Header() {
|
||||||
const location = useLocation();
|
const { byCategory } = useGenerators();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setIsScrolled(window.scrollY > 10);
|
const onScroll = () => setIsScrolled(window.scrollY > 10);
|
||||||
window.addEventListener("scroll", onScroll);
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navLinkClass = (isActive: boolean) =>
|
return (
|
||||||
`px-3 py-2 rounded-md text-sm font-medium transition ${isActive
|
<Disclosure
|
||||||
? "text-blue-600 font-semibold"
|
as="header"
|
||||||
: "text-gray-700 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400"
|
className={`sticky top-0 z-50 border-b bg-white/90 backdrop-blur transition-all dark:bg-zinc-900/90 ${isScrolled ? "border-gray-200 shadow-sm dark:border-zinc-800" : "border-transparent"}`}
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Disclosure as="nav">
|
|
||||||
{({ open }) => (
|
|
||||||
<header
|
|
||||||
className={`fixed top-0 w-full z-50 bg-white dark:bg-zinc-900 shadow transition-all duration-300 ${
|
|
||||||
isScrolled ? "py-2 shadow-md" : "py-4"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center">
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
{/* LOGO */}
|
<Link to="/" className="flex items-center gap-2">
|
||||||
<Link to="/" className="flex items-center space-x-2">
|
<img src={Logo} alt="" className="h-8 w-8" />
|
||||||
<img src={Logo} alt="Logo" className="h-8 w-8" />
|
<span className="text-lg font-bold text-gray-800 dark:text-white">Factory Id</span>
|
||||||
<span className="font-bold text-lg text-gray-800 dark:text-white">
|
</Link>
|
||||||
Factory Id
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
<nav className="hidden items-center gap-2 md:flex" aria-label="Principal">
|
||||||
<div className="hidden md:flex space-x-6 items-center">
|
<NavLink to="/" end className={linkClass}>Início</NavLink>
|
||||||
{navItems.map((item) =>
|
|
||||||
item.subItems ? (
|
|
||||||
<Menu as="div" className="relative" key={item.name}>
|
|
||||||
<Menu.Button className="flex items-center gap-1 text-gray-700 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 text-sm font-medium">
|
|
||||||
{item.name}
|
|
||||||
<ChevronDownIcon className="w-4 h-4" />
|
|
||||||
</Menu.Button>
|
|
||||||
<Transition
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Menu.Items className="absolute mt-2 w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
|
||||||
<div className="py-1">
|
|
||||||
{item.subItems.map((sub) => (
|
|
||||||
<Menu.Item key={sub.name}>
|
|
||||||
{({ active }) => (
|
|
||||||
<Link
|
|
||||||
to={sub.path}
|
|
||||||
className={`block px-4 py-2 text-sm ${
|
|
||||||
active
|
|
||||||
? "bg-gray-100 dark:bg-zinc-700 text-blue-600"
|
|
||||||
: "text-gray-700 dark:text-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sub.name}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
</Menu>
|
|
||||||
) : (
|
|
||||||
<NavLink
|
|
||||||
to={item.path}
|
|
||||||
key={item.name}
|
|
||||||
className={({ isActive }) => navLinkClass(isActive)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<DarkModeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
<Menu as="div" className="relative">
|
||||||
<div className="md:hidden">
|
<MenuButton className="flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400">
|
||||||
<Disclosure.Button className="text-gray-700 hover:text-blue-500 dark:text-gray-300">
|
Geradores <ChevronDownIcon className="h-4 w-4" />
|
||||||
{open ? (
|
</MenuButton>
|
||||||
<XMarkIcon className="h-6 w-6" />
|
<MenuItems
|
||||||
) : (
|
transition
|
||||||
<Bars3Icon className="h-6 w-6" />
|
anchor="bottom start"
|
||||||
)}
|
className="z-50 mt-2 w-[36rem] origin-top-left rounded-xl bg-white p-4 shadow-lg ring-1 ring-black/5 transition duration-100 ease-out focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0 dark:bg-zinc-800 dark:ring-white/10"
|
||||||
</Disclosure.Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Panel */}
|
|
||||||
<Disclosure.Panel className="md:hidden bg-white dark:bg-zinc-900 shadow-inner">
|
|
||||||
<div className="px-4 pt-2 pb-4 space-y-1">
|
|
||||||
{navItems.map((item) =>
|
|
||||||
item.subItems ? (
|
|
||||||
<div key={item.name}>
|
|
||||||
<span className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
<div className="pl-4 space-y-1">
|
|
||||||
{item.subItems.map((sub) => (
|
|
||||||
<NavLink
|
|
||||||
to={sub.path}
|
|
||||||
key={sub.name}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block text-sm px-3 py-1 rounded ${
|
|
||||||
isActive
|
|
||||||
? "text-blue-600 font-semibold"
|
|
||||||
: "text-gray-700 dark:text-gray-300"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{sub.name}
|
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||||
</NavLink>
|
{byCategory.map(([category, generators]) => (
|
||||||
))}
|
<div key={category}>
|
||||||
</div>
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400">{category}</p>
|
||||||
</div>
|
{generators.map((g) => (
|
||||||
) : (
|
<MenuItem key={g.id}>
|
||||||
<NavLink
|
<Link
|
||||||
to={item.path}
|
to={`/gerador/${g.id}`}
|
||||||
key={item.name}
|
className="block rounded-md px-2 py-1.5 text-sm text-gray-700 data-[focus]:bg-blue-50 data-[focus]:text-blue-700 dark:text-gray-200 dark:data-[focus]:bg-zinc-700"
|
||||||
className={({ isActive }) =>
|
>
|
||||||
`block text-sm px-3 py-2 rounded-md ${
|
{g.name}
|
||||||
isActive
|
</Link>
|
||||||
? "text-blue-600 font-semibold"
|
</MenuItem>
|
||||||
: "text-gray-700 dark:text-gray-300"
|
))}
|
||||||
}`
|
</div>
|
||||||
}
|
))}
|
||||||
>
|
</div>
|
||||||
{item.name}
|
</MenuItems>
|
||||||
</NavLink>
|
</Menu>
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="pt-2">
|
|
||||||
<DarkModeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Disclosure.Panel>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
</Disclosure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
<NavLink to="/validar" className={linkClass}>Validador</NavLink>
|
||||||
|
<a href={docsUrl} target="_blank" rel="noreferrer" className={linkClass({ isActive: false })}>API</a>
|
||||||
|
<DarkModeToggle />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 md:hidden">
|
||||||
|
<DarkModeToggle />
|
||||||
|
<DisclosureButton className="group rounded-md p-2 text-gray-700 dark:text-gray-300" aria-label="Menu">
|
||||||
|
<MenuIcon className="h-6 w-6 group-data-[open]:hidden" />
|
||||||
|
<XIcon className="hidden h-6 w-6 group-data-[open]:block" />
|
||||||
|
</DisclosureButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DisclosurePanel className="border-t border-gray-200 px-4 pb-4 pt-2 md:hidden dark:border-zinc-800">
|
||||||
|
{({ close }) => (
|
||||||
|
<nav className="flex flex-col gap-1" aria-label="Principal (móvel)">
|
||||||
|
<NavLink to="/" end className={linkClass} onClick={() => close()}>Início</NavLink>
|
||||||
|
<NavLink to="/validar" className={linkClass} onClick={() => close()}>Validador</NavLink>
|
||||||
|
{byCategory.map(([category, generators]) => (
|
||||||
|
<div key={category} className="mt-2">
|
||||||
|
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">{category}</p>
|
||||||
|
{generators.map((g) => (
|
||||||
|
<NavLink key={g.id} to={`/gerador/${g.id}`} className={linkClass} onClick={() => close()}>
|
||||||
|
<span className="block">{g.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<a href={docsUrl} target="_blank" rel="noreferrer" className={linkClass({ isActive: false })}>API</a>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// components/layout/Layout.tsx
|
import type { ReactNode } from "react";
|
||||||
import Footer from './Footer';
|
import Footer from "./Footer";
|
||||||
import Header from './Header';
|
import Header from "./Header";
|
||||||
|
|
||||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 transition-colors duration-300">
|
<div className="flex min-h-screen flex-col bg-white text-zinc-900 transition-colors dark:bg-zinc-900 dark:text-zinc-100">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow mx-auto w-full">
|
<main className="w-full flex-grow">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
84
geradoresfe/src/lib/api.ts
Normal file
84
geradoresfe/src/lib/api.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Cliente da API. Por omissão usa caminhos relativos (/api/v1): em dev o Vite faz proxy,
|
||||||
|
// em produção o nginx encaminha /api para o backend. VITE_API_URL permite apontar para outro host.
|
||||||
|
const BASE = `${(import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "")}/api/v1`;
|
||||||
|
|
||||||
|
export type OptionChoice = { value: string; label: string };
|
||||||
|
|
||||||
|
export type GeneratorOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
choices: OptionChoice[];
|
||||||
|
defaultValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Generator = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
hasChecksum: boolean;
|
||||||
|
canValidate: boolean;
|
||||||
|
options: GeneratorOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeneratedItem = {
|
||||||
|
value: string;
|
||||||
|
fields?: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationResult = {
|
||||||
|
isValid: boolean;
|
||||||
|
formatMatch: boolean;
|
||||||
|
message: string;
|
||||||
|
normalized?: string | null;
|
||||||
|
details?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectMatch = {
|
||||||
|
generatorId: string;
|
||||||
|
generatorName: string;
|
||||||
|
result: ValidationResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params ?? {}))
|
||||||
|
if (v !== undefined && v !== "") query.set(k, String(v));
|
||||||
|
const qs = query.toString();
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${BASE}${path}${qs ? `?${qs}` : ""}`);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("Não foi possível contactar o servidor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const problem = await response.json().catch(() => null);
|
||||||
|
const firstError = problem?.errors ? Object.values(problem.errors).flat()[0] : null;
|
||||||
|
throw new ApiError(String(firstError ?? problem?.title ?? `Erro ${response.status}`));
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listGenerators: () => get<Generator[]>("/generators"),
|
||||||
|
|
||||||
|
generate: (id: string, options: Record<string, string>, count = 1) =>
|
||||||
|
get<{ generator: string; items: GeneratedItem[] }>(`/generators/${encodeURIComponent(id)}/generate`, { ...options, count })
|
||||||
|
.then((r) => r.items),
|
||||||
|
|
||||||
|
validate: (id: string, value: string) =>
|
||||||
|
get<ValidationResult>(`/generators/${encodeURIComponent(id)}/validate`, { value }),
|
||||||
|
|
||||||
|
detect: (value: string) =>
|
||||||
|
get<{ value: string; matches: DetectMatch[] }>("/validate", { value }).then((r) => r.matches),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultOptions(generator: Generator): Record<string, string> {
|
||||||
|
return Object.fromEntries(generator.options.map((o) => [o.key, o.defaultValue]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsUrl =`${(import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "")}/docs`;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user