commit 5047288f04c0a4ba16d2e48600edb53ac2474d6d Author: Ismail Hamzah Date: Tue Jul 21 13:38:38 2026 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a125955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# OS files +Thumbs.db +.DS_Store +*.swp +*.swo + +# IDE +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# .NET +bin/ +obj/ +*.user +*.suo +*.cache +*.log +*.vs/ +packages/ +*.nupkg + +# Docker +.dockerignore + +# Uploads & Logs +wwwroot/uploads/* +!wwwroot/uploads/.gitkeep +wwwroot/xlogs/* +!wwwroot/xlogs/.gitkeep \ No newline at end of file diff --git a/ConfigBackEnd/Attributes/AuthorizeAttribute.cs b/ConfigBackEnd/Attributes/AuthorizeAttribute.cs new file mode 100644 index 0000000..9afaed8 --- /dev/null +++ b/ConfigBackEnd/Attributes/AuthorizeAttribute.cs @@ -0,0 +1,18 @@ +namespace Indotalent.ConfigBackEnd.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +public class AuthorizeAttribute : Attribute +{ + public string Role { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + public string Policy { get; set; } = string.Empty; + + public AuthorizeAttribute() + { + } + + public AuthorizeAttribute(string role) + { + Role = role; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs b/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs new file mode 100644 index 0000000..90d308d --- /dev/null +++ b/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs @@ -0,0 +1,68 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Attributes; +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR; +using System.Reflection; + +public class AuthorizationBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ICurrentUserService _currentUserService; + + public AuthorizationBehaviour(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var authorizeAttributes = request.GetType().GetCustomAttributes(); + + if (authorizeAttributes.Any()) + { + if (_currentUserService.UserId == null) + { + throw new UnauthorizedAccessException(); + } + + var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Role)); + + if (authorizeAttributesWithRoles.Any()) + { + var authorized = false; + foreach (var role in authorizeAttributesWithRoles.Select(a => a.Role)) + { + var isInRole = await _currentUserService.IsInRoleAsync(role.Trim()); + if (isInRole) + { + authorized = true; + break; + } + } + + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + + var authorizeAttributesWithPermissions = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Permission)); + + if (authorizeAttributesWithPermissions.Any()) + { + foreach (var permission in authorizeAttributesWithPermissions.Select(a => a.Permission)) + { + var hasPermission = await _currentUserService.HasPermissionAsync(permission.Trim()); + if (!hasPermission) + { + throw new ForbiddenAccessException(); + } + } + } + } + + return await next(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/LoggingBehaviour.cs b/ConfigBackEnd/Behaviours/LoggingBehaviour.cs new file mode 100644 index 0000000..a9912c2 --- /dev/null +++ b/ConfigBackEnd/Behaviours/LoggingBehaviour.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR.Pipeline; +using Microsoft.Extensions.Logging; + +public class LoggingBehaviour : IRequestPreProcessor + where TRequest : notnull +{ + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUserService; + + public LoggingBehaviour(ILogger logger, ICurrentUserService currentUserService) + { + _logger = logger; + _currentUserService = currentUserService; + } + + public Task Process(TRequest request, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var userId = _currentUserService.UserId ?? string.Empty; + var userName = _currentUserService.UserName ?? string.Empty; + + _logger.LogInformation("Request: {Name} {UserId} {UserName} {@Request}", + requestName, userId, userName, request); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs b/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 0000000..4e455d1 --- /dev/null +++ b/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,46 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +public class PerformanceBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly Stopwatch _timer; + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUserService; + + public PerformanceBehaviour( + ILogger logger, + ICurrentUserService currentUserService) + { + _timer = new Stopwatch(); + _logger = logger; + _currentUserService = currentUserService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await next(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + var userId = _currentUserService.UserId ?? string.Empty; + var userName = _currentUserService.UserName ?? string.Empty; + + _logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", + requestName, elapsedMilliseconds, userId, userName, request); + } + + return response; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs b/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 0000000..08cfaa2 --- /dev/null +++ b/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,33 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.Shared.Consts; +using MediatR; +using Microsoft.Extensions.Logging; + +public class UnhandledExceptionBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger _logger; + + public UnhandledExceptionBehaviour(ILogger logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + + _logger.LogError(ex, GlobalConsts.BehaviourError + " {Name} {@Request}", + requestName, request); + + throw; + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/ValidationBehaviour.cs b/ConfigBackEnd/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..5325860 --- /dev/null +++ b/ConfigBackEnd/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,42 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using FluentValidation; +using MediatR; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + throw new ValidationException(failures); + } + } + + return await next(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/DI.cs b/ConfigBackEnd/DI.cs new file mode 100644 index 0000000..77f2d52 --- /dev/null +++ b/ConfigBackEnd/DI.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd; + +using FluentValidation; +using Indotalent.ConfigBackEnd.Behaviours; +using MediatR; +using MediatR.Pipeline; +using Microsoft.Extensions.DependencyInjection; + +public static class DI +{ + public static IServiceCollection AddConfigBackEndDI(this IServiceCollection services) + { + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(DI).Assembly); + + cfg.AddRequestPreProcessor(typeof(IRequestPreProcessor<>), typeof(LoggingBehaviour<>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehaviour<,>)); + + cfg.Lifetime = ServiceLifetime.Scoped; + }); + + services.AddValidatorsFromAssembly(typeof(DI).Assembly); + + return services; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs b/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs new file mode 100644 index 0000000..62d4f1d --- /dev/null +++ b/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class AlreadyExistsException : Exception +{ + public AlreadyExistsException() + : base() + { + } + + public AlreadyExistsException(string message) + : base(message) + { + } + + public AlreadyExistsException(string message, Exception innerException) + : base(message, innerException) + { + } + + public AlreadyExistsException(string name, object key) + : base($"Entity \"{name}\" ({key}) already exists.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs b/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs new file mode 100644 index 0000000..8ed5281 --- /dev/null +++ b/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs @@ -0,0 +1,17 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class ForbiddenAccessException : Exception +{ + public ForbiddenAccessException() : base() + { + } + + public ForbiddenAccessException(string message) : base(message) + { + } + + public ForbiddenAccessException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/ConfigBackEnd/Exceptions/MismatchException.cs b/ConfigBackEnd/Exceptions/MismatchException.cs new file mode 100644 index 0000000..86380fb --- /dev/null +++ b/ConfigBackEnd/Exceptions/MismatchException.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class MismatchException : Exception +{ + public MismatchException() + : base() + { + } + + public MismatchException(string message) + : base(message) + { + } + + public MismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public MismatchException(string name, object expected, object actual) + : base($"Mismatch detected for \"{name}\". Expected: {expected}, but received: {actual}.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/NotFoundException.cs b/ConfigBackEnd/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..5ed30c3 --- /dev/null +++ b/ConfigBackEnd/Exceptions/NotFoundException.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class NotFoundException : Exception +{ + public NotFoundException() + : base() + { + } + + public NotFoundException(string message) + : base(message) + { + } + + public NotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NotFoundException(string name, object key) + : base($"Entity \"{name}\" ({key}) was not found.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/ValidationException.cs b/ConfigBackEnd/Exceptions/ValidationException.cs new file mode 100644 index 0000000..b397890 --- /dev/null +++ b/ConfigBackEnd/Exceptions/ValidationException.cs @@ -0,0 +1,36 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +using FluentValidation.Results; +using System.Collections.Generic; +using System.Linq; + +public class ValidationException : Exception +{ + public IDictionary Errors { get; } + + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public ValidationException(string message) + : base(message) + { + Errors = new Dictionary(); + } + + public ValidationException(string message, Exception innerException) + : base(message, innerException) + { + Errors = new Dictionary(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/AppDbContextExtensions.cs b/ConfigBackEnd/Extensions/AppDbContextExtensions.cs new file mode 100644 index 0000000..42068b0 --- /dev/null +++ b/ConfigBackEnd/Extensions/AppDbContextExtensions.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Indotalent.ConfigBackEnd.Extensions; + +public static class AppDbContextExtensions +{ + public static async Task GenerateAutoNumberAsync( + this AppDbContext context, + string entityName, + string prefixTemplate, + string? suffixTemplate = null, + int paddingLength = 4, + bool useYear = true, + bool useMonth = false, + CancellationToken ct = default) + { + var generator = context.GetService(); + + return await generator.GenerateNextNumberAsync( + entityName, + prefixTemplate, + suffixTemplate, + paddingLength, + useYear, + useMonth, + ct); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/DateTimeExtensions.cs b/ConfigBackEnd/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..f876323 --- /dev/null +++ b/ConfigBackEnd/Extensions/DateTimeExtensions.cs @@ -0,0 +1,14 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +public static class DateTimeExtensions +{ + private const string DefaultFormat = "dd MMM yyyy, HH:mm"; + + public static string ToString(this DateTimeOffset? dateTimeOffset, string format = DefaultFormat) + { + if (!dateTimeOffset.HasValue) return "-"; + + return dateTimeOffset.Value.ToLocalTime().ToString(format); + } + +} diff --git a/ConfigBackEnd/Extensions/EnumExtension.cs b/ConfigBackEnd/Extensions/EnumExtension.cs new file mode 100644 index 0000000..0648128 --- /dev/null +++ b/ConfigBackEnd/Extensions/EnumExtension.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Reflection; + +namespace Indotalent.ConfigBackEnd.Extensions; + +public static class EnumExtension +{ + public static string GetDescription(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field == null) + { + return value.ToString(); + } + + var attribute = field.GetCustomAttribute(); + + return attribute == null ? value.ToString() : attribute.Description; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/GenericExtensions.cs b/ConfigBackEnd/Extensions/GenericExtensions.cs new file mode 100644 index 0000000..88da31b --- /dev/null +++ b/ConfigBackEnd/Extensions/GenericExtensions.cs @@ -0,0 +1,86 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +public static class GenericExtensions +{ + public static bool IsNullOrEmpty(this IEnumerable? enumerable) + { + return enumerable == null || !enumerable.Any(); + } + + public static T? ToObject(this string json) + { + if (string.IsNullOrWhiteSpace(json)) return default; + + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + + public static string ToJson(this T obj) + { + return JsonSerializer.Serialize(obj); + } + + public static TDestination MapTo(this object source) + where TDestination : new() + { + var json = JsonSerializer.Serialize(source); + return JsonSerializer.Deserialize(json) ?? new TDestination(); + } + + public static string ToCurrency(this decimal value, string culture = "id-ID") + { + return value.ToString("C0", new System.Globalization.CultureInfo(culture)); + } + + public static bool IsBetween(this T value, T low, T high) where T : IComparable + { + return value.CompareTo(low) >= 0 && value.CompareTo(high) <= 0; + } + + public static IResult ToApiResponse(this PagedList result) + { + return Results.Ok(new ApiResponse> + { + IsSuccess = true, + StatusCode = 200, + Value = result.Value, + Pagination = new PaginationMetadata + { + Count = result.Count, + Top = result.Top, + Skip = result.Skip + }, + ServerTime = DateTime.UtcNow + }); + } + + public static IResult ToApiResponse(this T? result, string? message = null, int statusCode = 200) + { + if (result == null) + { + return Results.NotFound(new ApiResponse + { + IsSuccess = false, + StatusCode = 404, + Message = message ?? "Data not found", + ServerTime = DateTime.UtcNow + }); + } + + return Results.Ok(new ApiResponse + { + IsSuccess = true, + StatusCode = statusCode, + Message = message, + Value = result, + Pagination = null, + ServerTime = DateTime.UtcNow + }); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/IQueryableExtensions.cs b/ConfigBackEnd/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000..6b2b51a --- /dev/null +++ b/ConfigBackEnd/Extensions/IQueryableExtensions.cs @@ -0,0 +1,63 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using Indotalent.Data.Interfaces; +using Indotalent.Shared.Models; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +public static class IQueryableExtensions +{ + public static IQueryable WhereIf( + this IQueryable query, + bool condition, + Expression> predicate) + { + return condition ? query.Where(predicate) : query; + } + + public static async Task> ToPagedListAsync( + this IQueryable query, + int skip, + int top) + { + top = top <= 0 ? 5 : top; + skip = skip < 0 ? 0 : skip; + + var count = await query.CountAsync(); + + var items = await query + .Skip(skip) + .Take(top) + .ToListAsync(); + + return new PagedList(items, count, skip, top); + } + + public static IQueryable OrderByPropertyName( + this IQueryable query, + string? propertyName, + bool isDescending) + { + if (string.IsNullOrWhiteSpace(propertyName)) return query; + + var parameter = Expression.Parameter(typeof(T), "x"); + var property = Expression.Property(parameter, propertyName); + var lambda = Expression.Lambda(property, parameter); + + var methodName = isDescending ? "OrderByDescending" : "OrderBy"; + var resultExpression = Expression.Call( + typeof(Queryable), + methodName, + new Type[] { typeof(T), property.Type }, + query.Expression, + Expression.Quote(lambda)); + + return query.Provider.CreateQuery(resultExpression); + } + + public static IQueryable NotDeletedOnly(this IQueryable query) + where T : class, IHasIsDeleted + { + return query.Where(x => !x.IsDeleted); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/ListExtensions.cs b/ConfigBackEnd/Extensions/ListExtensions.cs new file mode 100644 index 0000000..bc8a78a --- /dev/null +++ b/ConfigBackEnd/Extensions/ListExtensions.cs @@ -0,0 +1,51 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using System; +using System.Collections.Generic; +using System.Linq; + +public static class ListExtensions +{ + public static void AddIf(this IList list, bool condition, T item) + { + if (condition) + { + list.Add(item); + } + } + + public static IEnumerable> Chunk(this IEnumerable source, int size) + { + while (source.Any()) + { + yield return source.Take(size); + source = source.Skip(size); + } + } + + public static void Shuffle(this IList list) + { + var rng = new Random(); + int n = list.Count; + while (n > 1) + { + n--; + int k = rng.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + + public static IEnumerable DistinctBy(this IEnumerable source, Func keySelector) + { + var seenKeys = new HashSet(); + foreach (var element in source) + { + if (seenKeys.Add(keySelector(element))) + { + yield return element; + } + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/StringExtensions.cs b/ConfigBackEnd/Extensions/StringExtensions.cs new file mode 100644 index 0000000..ba5cc61 --- /dev/null +++ b/ConfigBackEnd/Extensions/StringExtensions.cs @@ -0,0 +1,92 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using System.Text.RegularExpressions; + +public static class StringExtensions +{ + public static string ToShortNameVowel(this string? value, int length = 3) + { + if (string.IsNullOrWhiteSpace(value)) return "GEN"; + + var vowels = new string(value.Where(c => "AEIOUaeiou".Contains(c)).ToArray()); + var baseName = vowels.Length >= length ? vowels : value; + + return new string(baseName.Trim().Take(length).ToArray()).ToUpper(); + } + + public static string ToShortNameConsonant(this string? value, int length = 3) + { + if (string.IsNullOrWhiteSpace(value)) return "GEN"; + + var consonants = new string(value.Where(c => char.IsLetter(c) && !"AEIOUaeiou".Contains(c)).ToArray()); + var baseName = consonants.Length >= length ? consonants : value; + + return new string(baseName.Trim().Take(length).ToArray()).ToUpper(); + } + + public static string ToInitial(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return "XX"; + + var words = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (words.Length >= 2) + { + var firstInitial = words[0][0]; + var secondInitial = words[1][0]; + return $"{firstInitial}{secondInitial}".ToUpper(); + } + + return new string(value.Trim().Take(2).ToArray()).ToUpper(); + } + + public static bool IsNotNullOrWhiteSpace(this string? value) + { + return !string.IsNullOrWhiteSpace(value); + } + + public static string ToTitleCase(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + return System.Globalization.CultureInfo.CurrentCulture.TextInfo + .ToTitleCase(value.ToLower().Trim()); + } + + public static string ToSlug(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + var str = value.ToLower().Trim(); + str = Regex.Replace(str, @"[^a-z0-9\s-]", ""); + str = Regex.Replace(str, @"[\s-]+", " ").Trim(); + str = str.Replace(" ", "-"); + + return str; + } + + public static string MaskEmail(this string? email) + { + if (string.IsNullOrWhiteSpace(email) || !email.Contains("@")) return "******"; + + var parts = email.Split('@'); + var name = parts[0]; + var domain = parts[1]; + + if (name.Length <= 2) return $"{name}***@{domain}"; + + return $"{name[..2]}***{name[^1..]}@{domain}"; + } + + public static string Truncate(this string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return value.Length <= maxLength ? value : $"{value[..maxLength]}..."; + } + + public static string RemoveNonNumeric(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return Regex.Replace(value, "[^0-9]", ""); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Interfaces/ICurrentUserService.cs b/ConfigBackEnd/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..f9d0615 --- /dev/null +++ b/ConfigBackEnd/Interfaces/ICurrentUserService.cs @@ -0,0 +1,11 @@ +namespace Indotalent.ConfigBackEnd.Interfaces; + +public interface ICurrentUserService +{ + string? UserId { get; set; } + string? UserName { get; set; } + string? Email { get; set; } + string? FullName { get; set; } + Task IsInRoleAsync(string role); + Task HasPermissionAsync(string permission); +} diff --git a/ConfigBackEnd/Mappings/IMapFrom.cs b/ConfigBackEnd/Mappings/IMapFrom.cs new file mode 100644 index 0000000..07202eb --- /dev/null +++ b/ConfigBackEnd/Mappings/IMapFrom.cs @@ -0,0 +1,8 @@ +namespace Indotalent.ConfigBackEnd.Mappings; + +using AutoMapper; + +public interface IMapFrom +{ + void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType()); +} \ No newline at end of file diff --git a/ConfigBackEnd/Mappings/MappingProfile.cs b/ConfigBackEnd/Mappings/MappingProfile.cs new file mode 100644 index 0000000..fa77a63 --- /dev/null +++ b/ConfigBackEnd/Mappings/MappingProfile.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd.Mappings; + +using AutoMapper; +using System.Reflection; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly()); + } + + private void ApplyMappingsFromAssembly(Assembly assembly) + { + var types = assembly.GetExportedTypes() + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>))) + .ToList(); + + foreach (var type in types) + { + var instance = Activator.CreateInstance(type); + + var methodInfo = type.GetMethod("Mapping") + ?? type.GetInterface("IMapFrom`1")?.GetMethod("Mapping"); + + methodInfo?.Invoke(instance, new object[] { this }); + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs b/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..8a7d805 --- /dev/null +++ b/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,105 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Shared.Consts; +using Indotalent.Shared.Models; +using System.Net; +using System.Text.Json; + +namespace Indotalent.ConfigBackEnd.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var code = HttpStatusCode.InternalServerError; + var message = "An unexpected error occurred on the server."; + var errors = new List(); + + switch (exception) + { + case ValidationException validationException: + code = HttpStatusCode.BadRequest; + message = "One or more validation failures have occurred."; + foreach (var error in validationException.Errors) + { + errors.AddRange(error.Value); + } + break; + + case NotFoundException notFoundException: + code = HttpStatusCode.NotFound; + message = notFoundException.Message; + errors.Add(notFoundException.Message); + break; + + case AlreadyExistsException alreadyExistsException: + code = HttpStatusCode.Conflict; + message = alreadyExistsException.Message; + errors.Add(alreadyExistsException.Message); + break; + + case MismatchException mismatchException: + code = HttpStatusCode.BadRequest; + message = mismatchException.Message; + errors.Add(mismatchException.Message); + break; + + case ForbiddenAccessException: + code = HttpStatusCode.Forbidden; + message = "You do not have permission to access this resource."; + errors.Add("Access denied to the requested resource."); + break; + + case UnauthorizedAccessException: + code = HttpStatusCode.Unauthorized; + message = "Authentication is required to access this resource."; + errors.Add("Session invalid or expired."); + break; + + default: + if (exception.StackTrace != null && exception.StackTrace.Contains(GlobalConsts.BehaviourError)) + { + _logger.LogError(exception, GlobalConsts.GlobalError + " {Message} on {Path}", + exception.Message, context.Request.Path); + } + message = "An unexpected error occurred on the HRM server."; + errors.Add(exception.Message); + break; + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)code; + + var response = new ApiResponse + { + IsSuccess = false, + StatusCode = (int)code, + Message = message, + Errors = errors, + ServerTime = DateTime.UtcNow + }; + + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + await context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions)); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Common/BaseAppPage.cs b/ConfigFrontEnd/Common/BaseAppPage.cs new file mode 100644 index 0000000..7aaeba4 --- /dev/null +++ b/ConfigFrontEnd/Common/BaseAppPage.cs @@ -0,0 +1,36 @@ +using Indotalent.ConfigFrontEnd.Extensions; +using Indotalent.ConfigFrontEnd.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Indotalent.ConfigFrontEnd.Common; + + +public abstract class BaseAppPage : ComponentBase +{ + [Inject] + protected AuthenticationStateProvider AuthStateProvider { get; set; } = default!; + + [Inject] + protected CurrentUserState State { get; set; } = default!; + + protected override async Task OnParametersSetAsync() + { + await SyncUserContext(); + await base.OnParametersSetAsync(); + } + + private async Task SyncUserContext() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + State.UserId = user.GetUserId(); + State.UserName = user.GetUserName(); + State.Email = user.GetEmail(); + State.FullName = user.GetFullName(); + } + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Common/BaseService.cs b/ConfigFrontEnd/Common/BaseService.cs new file mode 100644 index 0000000..073ebd9 --- /dev/null +++ b/ConfigFrontEnd/Common/BaseService.cs @@ -0,0 +1,153 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Features.Account.Login.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; +using System.Net; + +namespace Indotalent.ConfigFrontEnd.Common; + +public abstract class BaseService +{ + protected readonly IHttpClientFactory ClientFactory; + protected readonly NavigationManager Nav; + protected readonly ISnackbar Snackbar; + protected readonly ICurrentUserService CurrentUserService; + protected readonly TokenProvider TokenProvider; + + public BaseService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + { + ClientFactory = clientFactory; + Nav = nav; + Snackbar = snackbar; + CurrentUserService = currentUserService; + TokenProvider = tokenProvider; + } + + protected HttpClient CreateClient() + { + var client = ClientFactory.CreateClient(); + if (client.BaseAddress == null) + { + client.BaseAddress = new Uri(Nav.BaseUri); + } + return client; + } + + protected async Task?> ExecuteWithResponseAsync(RestClient client, RestRequest request) + { + try + { + if (!string.IsNullOrEmpty(TokenProvider.Token)) + request.AddOrUpdateHeader("Authorization", $"Bearer {TokenProvider.Token}"); + + if (!string.IsNullOrEmpty(TokenProvider.RefreshToken)) + request.AddOrUpdateHeader("X-Refresh-Token", TokenProvider.RefreshToken); + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddOrUpdateHeader("X-UserId", CurrentUserService.UserId); + request.AddOrUpdateHeader("X-UserName", CurrentUserService.UserName ?? string.Empty); + request.AddOrUpdateHeader("X-Email", CurrentUserService.Email ?? string.Empty); + request.AddOrUpdateHeader("X-FullName", CurrentUserService.FullName ?? string.Empty); + } + + var response = await client.ExecuteAsync>(request); + + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + var httpClient = CreateClient(); + + var refreshRequestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/account/refresh-token"); + + if (!string.IsNullOrEmpty(TokenProvider.RefreshToken)) + { + refreshRequestMessage.Headers.Add("X-Refresh-Token", TokenProvider.RefreshToken); + } + + var refreshResponse = await httpClient.SendAsync(refreshRequestMessage); + + if (refreshResponse.IsSuccessStatusCode) + { + var result = await refreshResponse.Content.ReadFromJsonAsync(); + if (result != null && !string.IsNullOrEmpty(result.Token)) + { + TokenProvider.Token = result.Token; + + request.AddOrUpdateHeader("Authorization", $"Bearer {TokenProvider.Token}"); + response = await client.ExecuteAsync>(request); + } + } + else + { + Nav.NavigateTo("/account/login"); + return default; + } + } + + if (response.Data != null) + { + if (!response.Data.IsSuccess) + { + HandleApiError(response.Data); + } + return response.Data; + } + + var fallbackMessage = GetFriendlyInfrastructureErrorMessage(response); + Snackbar.Add(fallbackMessage, Severity.Error); + + return new ApiResponse + { + IsSuccess = false, + StatusCode = (int)response.StatusCode, + Message = fallbackMessage + }; + } + catch (Exception ex) + { + var systemError = $"Client System Error: {ex.Message}"; + Snackbar.Add(systemError, Severity.Error); + return new ApiResponse { IsSuccess = false, Message = systemError }; + } + } + + private void HandleApiError(ApiResponse response) + { + var mainMessage = response.Message ?? "Operation failed"; + if (response.Errors != null && response.Errors.Any()) + { + var combinedErrors = string.Join(Environment.NewLine, response.Errors.Select(e => $"• {e}")); + Snackbar.Add(combinedErrors, Severity.Error, config => + { + config.ShowCloseIcon = true; + config.VisibleStateDuration = 3000; + }); + } + else + { + Snackbar.Add(mainMessage, Severity.Error); + } + } + + private string GetFriendlyInfrastructureErrorMessage(RestResponse response) + { + return response.StatusCode switch + { + HttpStatusCode.ServiceUnavailable => "Server is currently under maintenance.", + HttpStatusCode.GatewayTimeout => "Server took too long to respond.", + HttpStatusCode.Unauthorized => "Session expired. Please login again.", + HttpStatusCode.Forbidden => "You do not have permission to access this resource.", + HttpStatusCode.InternalServerError => "A critical error occurred on the server.", + _ => "Unable to reach the server. Please check your connection." + }; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/DI.cs b/ConfigFrontEnd/DI.cs new file mode 100644 index 0000000..e11c56e --- /dev/null +++ b/ConfigFrontEnd/DI.cs @@ -0,0 +1,37 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Models; +using Indotalent.ConfigFrontEnd.Service; +using MudBlazor; +using MudBlazor.Services; + +namespace Indotalent.ConfigFrontEnd; + +public static class DI +{ + public static IServiceCollection AddConfigFrontEndDI(this IServiceCollection services) + { + services.AddMudServices(config => + { + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.TopRight; + config.SnackbarConfiguration.PreventDuplicates = true; + config.SnackbarConfiguration.NewestOnTop = true; + config.SnackbarConfiguration.ShowCloseIcon = true; + config.SnackbarConfiguration.VisibleStateDuration = 5000; + config.SnackbarConfiguration.HideTransitionDuration = 300; + config.SnackbarConfiguration.ShowTransitionDuration = 300; + config.SnackbarConfiguration.SnackbarVariant = Variant.Filled; + }); + + + services.AddHttpClient("AppClient", client => + { + client.Timeout = TimeSpan.FromMinutes(30); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs b/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs new file mode 100644 index 0000000..a3fe292 --- /dev/null +++ b/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace Indotalent.ConfigFrontEnd.Extensions; + +public static class IdentityClaimPrincipalExtension +{ + public static string? GetUserId(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + public static string? GetEmail(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.Email)?.Value; + } + + public static string? GetUserName(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.Name)?.Value; + } + + public static string? GetFullName(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.GivenName)?.Value; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Filter/CurrentUserFilter.cs b/ConfigFrontEnd/Filter/CurrentUserFilter.cs new file mode 100644 index 0000000..f58890d --- /dev/null +++ b/ConfigFrontEnd/Filter/CurrentUserFilter.cs @@ -0,0 +1,44 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Extensions; + +namespace Indotalent.ConfigFrontEnd.Filter; + +public class CurrentUserFilter : IEndpointFilter +{ + private readonly ICurrentUserService _currentUserService; + + public CurrentUserFilter(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var user = httpContext.User; + + string? userId = user.GetUserId(); + string? email = user.GetEmail(); + string? userName = user.GetUserName(); + string? fullName = user.GetFullName(); + + + if (string.IsNullOrEmpty(userId)) + { + userId = httpContext.Request.Headers["X-UserId"].ToString(); + userName = httpContext.Request.Headers["X-UserName"].ToString(); + email = httpContext.Request.Headers["X-Email"].ToString(); + fullName = httpContext.Request.Headers["X-FullName"].ToString(); + } + + if (!string.IsNullOrEmpty(userId)) + { + _currentUserService.UserId = userId; + _currentUserService.UserName = userName; + _currentUserService.Email = email; + _currentUserService.FullName = fullName; + } + + return await next(context); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Models/CurrentUserState.cs b/ConfigFrontEnd/Models/CurrentUserState.cs new file mode 100644 index 0000000..7b20f43 --- /dev/null +++ b/ConfigFrontEnd/Models/CurrentUserState.cs @@ -0,0 +1,9 @@ +namespace Indotalent.ConfigFrontEnd.Models; + +public class CurrentUserState +{ + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FullName { get; set; } +} diff --git a/ConfigFrontEnd/Service/CurrentUserService.cs b/ConfigFrontEnd/Service/CurrentUserService.cs new file mode 100644 index 0000000..93d108e --- /dev/null +++ b/ConfigFrontEnd/Service/CurrentUserService.cs @@ -0,0 +1,48 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Models; + +namespace Indotalent.ConfigFrontEnd.Service; + +public class CurrentUserService : ICurrentUserService +{ + private readonly CurrentUserState _state; + + public CurrentUserService(CurrentUserState state) + { + _state = state; + } + + public string? UserId + { + get => _state.UserId; + set => _state.UserId = value; + } + + public string? UserName + { + get => _state.UserName; + set => _state.UserName = value; + } + + public string? Email + { + get => _state.Email; + set => _state.Email = value; + } + + public string? FullName + { + get => _state.FullName; + set => _state.FullName = value; + } + + public Task IsInRoleAsync(string role) + { + return Task.FromResult(false); + } + + public Task HasPermissionAsync(string permission) + { + return Task.FromResult(false); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Service/JwtService.cs b/ConfigFrontEnd/Service/JwtService.cs new file mode 100644 index 0000000..2e2adf5 --- /dev/null +++ b/ConfigFrontEnd/Service/JwtService.cs @@ -0,0 +1,35 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Indotalent.ConfigFrontEnd.Service; + +public static class JwtService +{ + public static string GenerateNewJwt(ApplicationUser user, JwtSettingsModel _jwtSettings) + { + var authClaims = new List + { + new(ClaimTypes.Name, user.UserName ?? ""), + new(ClaimTypes.Email, user.Email ?? ""), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.GivenName, user.FullName ?? ""), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Key)); + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + expires: DateTime.Now.AddMinutes(_jwtSettings.DurationInMinutes), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Data/Abstracts/BaseEntity.cs b/Data/Abstracts/BaseEntity.cs new file mode 100644 index 0000000..5173e00 --- /dev/null +++ b/Data/Abstracts/BaseEntity.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Interfaces; +using Indotalent.Shared.Utils; +using System.ComponentModel.DataAnnotations; + +namespace Indotalent.Data.Abstracts; + +public abstract class BaseEntity : IHasAudit, IHasIsDeleted +{ + [Key] + public string Id { get; set; } = SequentialGuidGenerator.NewSequentialId(); + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public bool IsDeleted { get; set; } = false; +} diff --git a/Data/Entities/ApplicationUser.cs b/Data/Entities/ApplicationUser.cs new file mode 100644 index 0000000..be6d9f9 --- /dev/null +++ b/Data/Entities/ApplicationUser.cs @@ -0,0 +1,41 @@ +using Indotalent.Data.Interfaces; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Data.Entities; + +public class ApplicationUser : IdentityUser, IHasIsDeleted +{ + public string? FullName { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime DateOfBirth { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public bool IsActive { get; set; } = true; + public bool IsDeleted { get; set; } = false; + public string? AvatarFile { get; set; } + public string? RefreshToken { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/AutoNumberSequence.cs b/Data/Entities/AutoNumberSequence.cs new file mode 100644 index 0000000..9643f4a --- /dev/null +++ b/Data/Entities/AutoNumberSequence.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Infrastructure.AutoNumberGenerator; + +namespace Indotalent.Data.Entities; + +public class AutoNumberSequence : BaseEntity, IAutoNumberGenerator +{ + public string? EntityName { get; set; } + public int? Year { get; set; } + public int? Month { get; set; } + public long? CurrentSequence { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } + public int? PaddingLength { get; set; } +} diff --git a/Data/Entities/Bill.cs b/Data/Entities/Bill.cs new file mode 100644 index 0000000..bc797e3 --- /dev/null +++ b/Data/Entities/Bill.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Bill : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? BillDate { get; set; } + public BillStatus BillStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Booking.cs b/Data/Entities/Booking.cs new file mode 100644 index 0000000..5b7ccfc --- /dev/null +++ b/Data/Entities/Booking.cs @@ -0,0 +1,27 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Booking : BaseEntity, IHasAutoNumber +{ + public string? Subject { get; set; } + public string? AutoNumber { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? StartTimezone { get; set; } + public string? EndTimezone { get; set; } + public string? Location { get; set; } + public string? Description { get; set; } + public bool? IsAllDay { get; set; } + public bool? IsReadOnly { get; set; } + public bool? IsBlock { get; set; } + public string? RecurrenceRule { get; set; } + public string? RecurrenceID { get; set; } + public string? FollowingID { get; set; } + public string? RecurrenceException { get; set; } + public BookingStatus Status { get; set; } + public string? BookingResourceId { get; set; } + public BookingResource? BookingResource { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/BookingGroup.cs b/Data/Entities/BookingGroup.cs new file mode 100644 index 0000000..1ccfa2e --- /dev/null +++ b/Data/Entities/BookingGroup.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class BookingGroup : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/BookingResource.cs b/Data/Entities/BookingResource.cs new file mode 100644 index 0000000..a8a8818 --- /dev/null +++ b/Data/Entities/BookingResource.cs @@ -0,0 +1,12 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class BookingResource : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? BookingGroupId { get; set; } + public BookingGroup? BookingGroup { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Company.cs b/Data/Entities/Company.cs new file mode 100644 index 0000000..db2438e --- /dev/null +++ b/Data/Entities/Company.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Company : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + public string CurrencyId { get; set; } = string.Empty; + public Currency? Currency { get; set; } + public string TaxIdentification { get; set; } = string.Empty; + public string BusinessLicense { get; set; } = string.Empty; + public string CompanyLogo { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; +} diff --git a/Data/Entities/Currency.cs b/Data/Entities/Currency.cs new file mode 100644 index 0000000..f5d2296 --- /dev/null +++ b/Data/Entities/Currency.cs @@ -0,0 +1,14 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Currency : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Code { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } +} diff --git a/Data/Entities/Customer.cs b/Data/Entities/Customer.cs new file mode 100644 index 0000000..2852c94 --- /dev/null +++ b/Data/Entities/Customer.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Customer : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? CustomerGroupId { get; set; } + public CustomerGroup? CustomerGroup { get; set; } + public string? CustomerCategoryId { get; set; } + public CustomerCategory? CustomerCategory { get; set; } + public ICollection CustomerContactList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/CustomerCategory.cs b/Data/Entities/CustomerCategory.cs new file mode 100644 index 0000000..5f18434 --- /dev/null +++ b/Data/Entities/CustomerCategory.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class CustomerCategory : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/CustomerContact.cs b/Data/Entities/CustomerContact.cs new file mode 100644 index 0000000..2ce9855 --- /dev/null +++ b/Data/Entities/CustomerContact.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class CustomerContact : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public Customer? Customer { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/CustomerGroup.cs b/Data/Entities/CustomerGroup.cs new file mode 100644 index 0000000..31cec84 --- /dev/null +++ b/Data/Entities/CustomerGroup.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class CustomerGroup : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/InventoryTransaction.cs b/Data/Entities/InventoryTransaction.cs new file mode 100644 index 0000000..5e35a2d --- /dev/null +++ b/Data/Entities/InventoryTransaction.cs @@ -0,0 +1,30 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class InventoryTransaction : BaseEntity, IHasAutoNumber +{ + public string? ModuleId { get; set; } + public string? ModuleName { get; set; } + public string? ModuleCode { get; set; } + public string? ModuleNumber { get; set; } + public DateTime? MovementDate { get; set; } + public InventoryTransactionStatus Status { get; set; } + public string? AutoNumber { get; set; } + public string? WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + public string? ProductId { get; set; } + public Product? Product { get; set; } + public double? Movement { get; set; } + public InventoryTransType? TransType { get; set; } + public double? Stock { get; set; } + public string? WarehouseFromId { get; set; } + public Warehouse? WarehouseFrom { get; set; } + public string? WarehouseToId { get; set; } + public Warehouse? WarehouseTo { get; set; } + public double? QtySCSys { get; set; } + public double? QtySCCount { get; set; } + public double? QtySCDelta { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Invoice.cs b/Data/Entities/Invoice.cs new file mode 100644 index 0000000..5bf6ad8 --- /dev/null +++ b/Data/Entities/Invoice.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Invoice : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public InvoiceStatus InvoiceStatus { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } + public SalesOrder? SalesOrder { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/PaymentDisburse.cs b/Data/Entities/PaymentDisburse.cs new file mode 100644 index 0000000..82fb26c --- /dev/null +++ b/Data/Entities/PaymentDisburse.cs @@ -0,0 +1,18 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class PaymentDisburse : BaseEntity, IHasAutoNumber +{ + public string? BillId { get; set; } + public Bill? Bill { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public PaymentMethod? PaymentMethod { get; set; } + public decimal? PaymentAmount { get; set; } + public PaymentDisburseStatus Status { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/PaymentMethod.cs b/Data/Entities/PaymentMethod.cs new file mode 100644 index 0000000..e42029b --- /dev/null +++ b/Data/Entities/PaymentMethod.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PaymentMethod : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/PaymentReceive.cs b/Data/Entities/PaymentReceive.cs new file mode 100644 index 0000000..f155639 --- /dev/null +++ b/Data/Entities/PaymentReceive.cs @@ -0,0 +1,18 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class PaymentReceive : BaseEntity, IHasAutoNumber +{ + public string? InvoiceId { get; set; } + public Invoice? Invoice { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public PaymentMethod? PaymentMethod { get; set; } + public decimal? PaymentAmount { get; set; } + public PaymentReceiveStatus Status { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Product.cs b/Data/Entities/Product.cs new file mode 100644 index 0000000..0e6dfdb --- /dev/null +++ b/Data/Entities/Product.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Product : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public decimal? UnitPrice { get; set; } + public bool? Physical { get; set; } = true; + public string? UnitMeasureId { get; set; } + public UnitMeasure? UnitMeasure { get; set; } + public string? ProductGroupId { get; set; } + public ProductGroup? ProductGroup { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/ProductGroup.cs b/Data/Entities/ProductGroup.cs new file mode 100644 index 0000000..1978515 --- /dev/null +++ b/Data/Entities/ProductGroup.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class ProductGroup : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/ProgramManager.cs b/Data/Entities/ProgramManager.cs new file mode 100644 index 0000000..fa23816 --- /dev/null +++ b/Data/Entities/ProgramManager.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class ProgramManager : BaseEntity, IHasAutoNumber +{ + public string? Title { get; set; } + public string? AutoNumber { get; set; } + public string? Summary { get; set; } + public ProgramManagerStatus Status { get; set; } + public ProgramManagerPriority Priority { get; set; } + public string? ProgramManagerResourceId { get; set; } + public ProgramManagerResource? ProgramManagerResource { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/ProgramManagerResource.cs b/Data/Entities/ProgramManagerResource.cs new file mode 100644 index 0000000..ccc001f --- /dev/null +++ b/Data/Entities/ProgramManagerResource.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class ProgramManagerResource : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/PurchaseOrder.cs b/Data/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..782078b --- /dev/null +++ b/Data/Entities/PurchaseOrder.cs @@ -0,0 +1,22 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; +using System.Numerics; + +namespace Indotalent.Data.Entities; + +public class PurchaseOrder : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public PurchaseOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public Vendor? Vendor { get; set; } + public string? TaxId { get; set; } + public Tax? Tax { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public ICollection PurchaseOrderItemList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/PurchaseOrderItem.cs b/Data/Entities/PurchaseOrderItem.cs new file mode 100644 index 0000000..924d531 --- /dev/null +++ b/Data/Entities/PurchaseOrderItem.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PurchaseOrderItem : BaseEntity +{ + public string? PurchaseOrderId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + public string? ProductId { get; set; } + public Product? Product { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } = 0; + public double? Quantity { get; set; } = 1; + public decimal? Total { get; set; } = 0; +} \ No newline at end of file diff --git a/Data/Entities/SalesOrder.cs b/Data/Entities/SalesOrder.cs new file mode 100644 index 0000000..4ea67e1 --- /dev/null +++ b/Data/Entities/SalesOrder.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class SalesOrder : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public SalesOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public Customer? Customer { get; set; } + public string? TaxId { get; set; } + public Tax? Tax { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public ICollection SalesOrderItemList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/SalesOrderItem.cs b/Data/Entities/SalesOrderItem.cs new file mode 100644 index 0000000..3572f80 --- /dev/null +++ b/Data/Entities/SalesOrderItem.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class SalesOrderItem : BaseEntity +{ + public string? SalesOrderId { get; set; } + public SalesOrder? SalesOrder { get; set; } + public string? ProductId { get; set; } + public Product? Product { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } = 0; + public double? Quantity { get; set; } = 1; + public decimal? Total { get; set; } = 0; +} \ No newline at end of file diff --git a/Data/Entities/SerilogLogs.cs b/Data/Entities/SerilogLogs.cs new file mode 100644 index 0000000..995a31b --- /dev/null +++ b/Data/Entities/SerilogLogs.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Indotalent.Data.Entities; + +public class SerilogLogs +{ + [Key] + public int Id { get; set; } + public string? Message { get; set; } + public string? MessageTemplate { get; set; } + public string? Level { get; set; } + public DateTimeOffset TimeStamp { get; set; } + public string? Exception { get; set; } + public string? Properties { get; set; } + public string? LogEvent { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Tax.cs b/Data/Entities/Tax.cs new file mode 100644 index 0000000..307a643 --- /dev/null +++ b/Data/Entities/Tax.cs @@ -0,0 +1,14 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Tax : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Code { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } +} diff --git a/Data/Entities/Todo.cs b/Data/Entities/Todo.cs new file mode 100644 index 0000000..2a67f89 --- /dev/null +++ b/Data/Entities/Todo.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Todo : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } + public string? Description { get; set; } + public ICollection TodoItemList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/TodoItem.cs b/Data/Entities/TodoItem.cs new file mode 100644 index 0000000..c11a18e --- /dev/null +++ b/Data/Entities/TodoItem.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class TodoItem : BaseEntity +{ + public string? Name { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? Description { get; set; } + public bool IsCompleted { get; set; } + public string? TodoId { get; set; } + public Todo? Todo { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/UnitMeasure.cs b/Data/Entities/UnitMeasure.cs new file mode 100644 index 0000000..166f07e --- /dev/null +++ b/Data/Entities/UnitMeasure.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class UnitMeasure : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Vendor.cs b/Data/Entities/Vendor.cs new file mode 100644 index 0000000..000554e --- /dev/null +++ b/Data/Entities/Vendor.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Vendor : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? VendorGroupId { get; set; } + public VendorGroup? VendorGroup { get; set; } + public string? VendorCategoryId { get; set; } + public VendorCategory? VendorCategory { get; set; } + public ICollection VendorContactList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/VendorCategory.cs b/Data/Entities/VendorCategory.cs new file mode 100644 index 0000000..af8c0d3 --- /dev/null +++ b/Data/Entities/VendorCategory.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class VendorCategory : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/VendorContact.cs b/Data/Entities/VendorContact.cs new file mode 100644 index 0000000..7640d86 --- /dev/null +++ b/Data/Entities/VendorContact.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class VendorContact : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public Vendor? Vendor { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/VendorGroup.cs b/Data/Entities/VendorGroup.cs new file mode 100644 index 0000000..3410d42 --- /dev/null +++ b/Data/Entities/VendorGroup.cs @@ -0,0 +1,10 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class VendorGroup : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Warehouse.cs b/Data/Entities/Warehouse.cs new file mode 100644 index 0000000..b8cae9f --- /dev/null +++ b/Data/Entities/Warehouse.cs @@ -0,0 +1,11 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Warehouse : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } + public bool? SystemWarehouse { get; set; } = false; +} \ No newline at end of file diff --git a/Data/Enums/BillStatus.cs b/Data/Enums/BillStatus.cs new file mode 100644 index 0000000..72a0808 --- /dev/null +++ b/Data/Enums/BillStatus.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum BillStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Partial Paid")] + PartialPaid = 3, + [Description("Full Paid")] + FullPaid = 4 +} diff --git a/Data/Enums/BookingStatus.cs b/Data/Enums/BookingStatus.cs new file mode 100644 index 0000000..c3f64eb --- /dev/null +++ b/Data/Enums/BookingStatus.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum BookingStatus +{ + [Description("Cancelled")] + Cancelled = 0, + [Description("Draft")] + Draft = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("OnProgress")] + OnProgress = 3, + [Description("Done")] + Done = 4 +} diff --git a/Data/Enums/InventoryTransType.cs b/Data/Enums/InventoryTransType.cs new file mode 100644 index 0000000..bdba07e --- /dev/null +++ b/Data/Enums/InventoryTransType.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum InventoryTransType +{ + [Description("In")] + In = 1, + [Description("Out")] + Out = -1, +} diff --git a/Data/Enums/InventoryTransactionStatus.cs b/Data/Enums/InventoryTransactionStatus.cs new file mode 100644 index 0000000..62668c9 --- /dev/null +++ b/Data/Enums/InventoryTransactionStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum InventoryTransactionStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/InvoiceStatus.cs b/Data/Enums/InvoiceStatus.cs new file mode 100644 index 0000000..818700b --- /dev/null +++ b/Data/Enums/InvoiceStatus.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum InvoiceStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Partial Paid")] + PartialPaid = 3, + [Description("Full Paid")] + FullPaid = 4 +} diff --git a/Data/Enums/PaymentDisburseStatus.cs b/Data/Enums/PaymentDisburseStatus.cs new file mode 100644 index 0000000..1fa8ad4 --- /dev/null +++ b/Data/Enums/PaymentDisburseStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PaymentDisburseStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/PaymentReceiveStatus.cs b/Data/Enums/PaymentReceiveStatus.cs new file mode 100644 index 0000000..717afab --- /dev/null +++ b/Data/Enums/PaymentReceiveStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PaymentReceiveStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/ProgramManagerPriority.cs b/Data/Enums/ProgramManagerPriority.cs new file mode 100644 index 0000000..9a0fe8f --- /dev/null +++ b/Data/Enums/ProgramManagerPriority.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum ProgramManagerPriority +{ + [Description("Low")] + Low = 0, + [Description("High")] + High = 1, + [Description("Normal")] + Normal = 2, + [Description("Critical")] + Critical = 3 +} diff --git a/Data/Enums/ProgramManagerStatus.cs b/Data/Enums/ProgramManagerStatus.cs new file mode 100644 index 0000000..5aa6dc1 --- /dev/null +++ b/Data/Enums/ProgramManagerStatus.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum ProgramManagerStatus +{ + [Description("Cancelled")] + Cancelled = 0, + [Description("Draft")] + Draft = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("OnProgress")] + OnProgress = 3, + [Description("Done")] + Done = 4 +} diff --git a/Data/Enums/PurchaseOrderStatus.cs b/Data/Enums/PurchaseOrderStatus.cs new file mode 100644 index 0000000..1b055fc --- /dev/null +++ b/Data/Enums/PurchaseOrderStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PurchaseOrderStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/SalesOrderStatus.cs b/Data/Enums/SalesOrderStatus.cs new file mode 100644 index 0000000..2e2579a --- /dev/null +++ b/Data/Enums/SalesOrderStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum SalesOrderStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Interfaces/IHasAudit.cs b/Data/Interfaces/IHasAudit.cs new file mode 100644 index 0000000..d0b2de7 --- /dev/null +++ b/Data/Interfaces/IHasAudit.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasAudit +{ + DateTimeOffset? CreatedAt { get; set; } + string? CreatedBy { get; set; } + DateTimeOffset? UpdatedAt { get; set; } + string? UpdatedBy { get; set; } +} \ No newline at end of file diff --git a/Data/Interfaces/IHasAutoNumber.cs b/Data/Interfaces/IHasAutoNumber.cs new file mode 100644 index 0000000..e09f3e9 --- /dev/null +++ b/Data/Interfaces/IHasAutoNumber.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasAutoNumber +{ + string? AutoNumber { get; set; } +} diff --git a/Data/Interfaces/IHasIsDeleted.cs b/Data/Interfaces/IHasIsDeleted.cs new file mode 100644 index 0000000..802223a --- /dev/null +++ b/Data/Interfaces/IHasIsDeleted.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasIsDeleted +{ + bool IsDeleted { get; set; } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0eabdc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# ===================================================================== +# Dockerfile untuk Blazor OMS (ASP.NET Core Blazor .NET 10) +# ===================================================================== +# Multi-stage build: +# Stage 1: Build aplikasi +# Stage 2: Run production +# ===================================================================== + +# ---- STAGE 1: BUILD ---- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj dan restore dependencies (cache layer) +COPY *.csproj . +RUN dotnet restore + +# Copy semua source code dan build +COPY . . +RUN dotnet publish -c Release -o /app --no-restore + +# ---- STAGE 2: RUN ---- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Install ICU libraries untuk Blazor +RUN apt-get update && apt-get install -y --no-install-recommends \ + libicu-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy hasil build dari stage 1 +COPY --from=build /app . + +# Set environment ke Production +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:8080 + +# Expose port +EXPOSE 8080 + +# Jalankan aplikasi +ENTRYPOINT ["dotnet", "Indotalent.dll"] \ No newline at end of file diff --git a/Features/Account/AccessDenied/AccessDeniedPage.razor b/Features/Account/AccessDenied/AccessDeniedPage.razor new file mode 100644 index 0000000..3b86227 --- /dev/null +++ b/Features/Account/AccessDenied/AccessDeniedPage.razor @@ -0,0 +1,63 @@ +@page "/account/access-denied" +@using Indotalent.Features.Root +@using MudBlazor +@layout MainLayout + +Access Denied - Insufficient Permissions + + + +
+ + + + Access Denied + + + Sorry, you do not have the required permissions to access this module.
+ This area is restricted to authorized administrative personnel only. +
+ + + + Error Code: AGS_AUTH_INSUFFICIENT_PERMISSIONS + + + Verification failed for the current security context. + + + +
+ + Back to Home + + + + Logout & Switch Account + +
+ +
+ +
+ +@code { + [Inject] private NavigationManager Nav { get; set; } = default!; + + private void GoHome() => Nav.NavigateTo("/"); + private void Logout() => Nav.NavigateTo("/account/logout"); +} \ No newline at end of file diff --git a/Features/Account/AccessDenied/RedirectToAccessDenied.razor b/Features/Account/AccessDenied/RedirectToAccessDenied.razor new file mode 100644 index 0000000..827c3dd --- /dev/null +++ b/Features/Account/AccessDenied/RedirectToAccessDenied.razor @@ -0,0 +1,19 @@ +@inject NavigationManager Navigation +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [AllowAnonymous] + + + + @{ + var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); + } + + + @{ + Navigation.NavigateTo("/account/access-denied"); + } + + + diff --git a/Features/Account/AccountEndPoint.cs b/Features/Account/AccountEndPoint.cs new file mode 100644 index 0000000..2d00c98 --- /dev/null +++ b/Features/Account/AccountEndPoint.cs @@ -0,0 +1,217 @@ +using Indotalent.ConfigFrontEnd.Service; +using Indotalent.Data.Entities; +using Indotalent.Features.Account.ForgotPassword.Cqrs; +using Indotalent.Features.Account.Login.Cqrs; +using Indotalent.Features.Account.Register.Cqrs; +using Indotalent.Features.Account.ResetPassword.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Indotalent.Features.Account; + +public static class AccountEndPoint +{ + public static IEndpointConventionBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/account").WithTags("Account"); + + group.MapPost("/signin-sso", async ([FromQuery] string email, UserManager userManager, SignInManager signInManager, IOptions jwtOptions, HttpContext context) => + { + if (string.IsNullOrWhiteSpace(email)) return Results.Json(new { status = 401, title = "Email is required" }, statusCode: 401); + + var user = await userManager.FindByEmailAsync(email); + + if (user == null || !user.IsActive) + { + return Results.Json(new { status = 401, title = "Account not found or inactive" }, statusCode: 401); + } + + var jwtSettings = jwtOptions.Value; + var token = JwtService.GenerateNewJwt(user, jwtSettings); + var refreshToken = Guid.NewGuid().ToString().Replace("-", ""); + + user.RefreshToken = refreshToken; + user.LastLoginAt = DateTime.Now; + await userManager.UpdateAsync(user); + + await signInManager.SignInAsync(user, isPersistent: true); + + context.Response.Cookies.Append("X-Auth-Token", token, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + context.Response.Cookies.Append("X-Refresh-Token", refreshToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + + return Results.Ok(new { status = 200, title = "SSO Login successful" }); + }); + + group.MapGet("/active-user-exists", async ([FromQuery] string email, UserManager userManager) => + { + if (string.IsNullOrWhiteSpace(email)) + { + return Results.Ok(new { exists = false, isActive = false }); + } + + var user = await userManager.FindByEmailAsync(email); + + if (user == null) + { + return Results.Ok(new { exists = false, isActive = false }); + } + + return Results.Ok(new { exists = true, isActive = user.IsActive }); + }); + + group.MapPost("/signin", async ([FromBody] LoginRequest request, IMediator mediator, HttpContext context, UserManager userManager) => + { + var command = new LoginCommand(request); + var response = await mediator.Send(command); + + if (response.Succeeded) + { + var user = await userManager.FindByEmailAsync(request.Email); + + if (user != null) + { + context.Response.Cookies.Append("X-Auth-Token", response.Token!, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + context.Response.Cookies.Append("X-Refresh-Token", user.RefreshToken!, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + } + + return Results.Ok(new { status = 200, title = response.Message }); + } + return Results.Json(new { status = 401, title = response.Message }, statusCode: 401); + }); + + + + group.MapPost("/refresh-token", async (HttpContext context) => + { + var userManager = context.RequestServices.GetRequiredService>(); + var jwtSettings = context.RequestServices.GetRequiredService>().Value; + + var refreshToken = context.Request.Headers["X-Refresh-Token"].ToString(); + + if (string.IsNullOrEmpty(refreshToken)) return Results.Unauthorized(); + + var user = await userManager.Users.FirstOrDefaultAsync(u => u.RefreshToken == refreshToken); + + if (user == null || !user.IsActive) return Results.Unauthorized(); + + var newToken = JwtService.GenerateNewJwt(user, jwtSettings); + + context.Response.Cookies.Append("X-Auth-Token", newToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + return Results.Ok(new LoginResponse { Succeeded = true, Token = newToken }); + }); + + group.MapPost("/signup", async ([FromBody] CreateUserRequest request, IMediator mediator) => + { + try + { + var command = new CreateUserCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + group.MapPost("/signout", async ( + SignInManager signInManager, + UserManager userManager, + HttpContext context) => + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (!string.IsNullOrEmpty(userId)) + { + var user = await userManager.FindByIdAsync(userId); + if (user != null) + { + user.RefreshToken = null; + await userManager.UpdateAsync(user); + } + } + + await signInManager.SignOutAsync(); + + context.Response.Cookies.Delete("X-Auth-Token"); + context.Response.Cookies.Delete("X-Refresh-Token"); + + return Results.Ok(new { status = 200, title = "Successfully signed out" }); + + }); + + group.MapPost("/forgot-password", async ([FromBody] ForgotPasswordRequest request, IMediator mediator) => + { + try + { + var command = new ForgotPasswordCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + group.MapPost("/logout", async (SignInManager signInManager) => + { + await signInManager.SignOutAsync(); + return Results.Ok(new { title = "Success user logout" }); + }).RequireAuthorization(); + + group.MapPost("/reset-password", async ([FromBody] ResetPasswordRequest request, IMediator mediator) => + { + try + { + var command = new ResetPasswordCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + return group; + } +} \ No newline at end of file diff --git a/Features/Account/AuthenticationLayout.razor b/Features/Account/AuthenticationLayout.razor new file mode 100644 index 0000000..b6cdef6 --- /dev/null +++ b/Features/Account/AuthenticationLayout.razor @@ -0,0 +1,350 @@ +@using Indotalent.Shared.Consts +@inherits LayoutComponentBase +@inject NavigationManager Navigation + + + + + + + + +
+ + +
+
+
+
+ + + +
+
+
@GlobalConsts.AppName
+
Enterprise Application Platform
+
+
+ +

One Platform,
All Your Business

+ +
+
+
+ + + +
+
+
Inventory & Procurement
+
Products, warehouses, purchase orders
+
+
+
+
+ + + +
+
+
Sales & Orders
+
Invoices, payments, customer management
+
+
+
+
+ + + +
+
+
Reports & Analytics
+
Real-time dashboards and data insights
+
+
+
+
+
+ + +
+
+ + +
+
+ + + +
+ @GlobalConsts.AppName +
+ + + + + @Body + + + + + +
+
+
+ +@code { + private void NavigateToHome() + { + Navigation.NavigateTo("/", forceLoad: true); + } +} \ No newline at end of file diff --git a/Features/Account/ConfirmEmail/ConfirmEmailPage.razor b/Features/Account/ConfirmEmail/ConfirmEmailPage.razor new file mode 100644 index 0000000..b78f603 --- /dev/null +++ b/Features/Account/ConfirmEmail/ConfirmEmailPage.razor @@ -0,0 +1,130 @@ +@page "/account/confirm-email" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using System.Text +@using Indotalent.Data.Entities +@inject UserManager UserManager +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +Confirming Email - @GlobalConsts.AppInitial + + + +
+
+
+ + + +
+
+ +

Verifying Your Account

+

Please wait while we validate your email address...

+ +
+
+ +@code { + [SupplyParameterFromQuery] public string? userId { get; set; } + [SupplyParameterFromQuery] public string? code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code)) + { + Snackbar.Add("Invalid or expired confirmation link.", Severity.Error); + Navigation.NavigateTo("/account/login"); + return; + } + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + Snackbar.Add("User record not found.", Severity.Error); + Navigation.NavigateTo("/account/login"); + return; + } + + try + { + var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await UserManager.ConfirmEmailAsync(user, decodedCode); + await Task.Delay(500); + if (result.Succeeded) + { + Snackbar.Add("Email verified successfully! You can now sign in.", Severity.Success); + } + else + { + Snackbar.Add("Verification failed. The link may have expired.", Severity.Error); + } + } + catch + { + Snackbar.Add("An error occurred during verification.", Severity.Error); + } + + Navigation.NavigateTo("/account/login"); + } +} \ No newline at end of file diff --git a/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor b/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor new file mode 100644 index 0000000..bf941a0 --- /dev/null +++ b/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor @@ -0,0 +1,216 @@ +@page "/account/forgot-password" +@layout AuthenticationLayout +@using System.ComponentModel.DataAnnotations +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + +Forgot Password - @GlobalConsts.AppInitial + + + + + + + +
+
+ + +
+ + +
+
+ + + +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private ForgotPasswordModel model = new(); + + private class ForgotPasswordModel + { + public string Email { get; set; } = ""; + } + + private async Task HandleForgotPassword() + { + await form.Validate(); + if (!success) return; + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiForgotPassword", model.Email); + + if (result.Status == 200) + { + Snackbar.Add("If your email is registered, a reset link has been sent.", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Failed to send reset link", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiResponse + { + public int Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs b/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs new file mode 100644 index 0000000..dc98150 --- /dev/null +++ b/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Account.ForgotPassword.Cqrs; + +public class ForgotPasswordRequest +{ + public string Email { get; set; } = string.Empty; +} + +public class ForgotPasswordResponse +{ + public string Message { get; set; } = string.Empty; +} + +public record ForgotPasswordCommand(ForgotPasswordRequest Data) : IRequest; + +public class ForgotPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ForgotPasswordHandler( + UserManager userManager, + IEmailSender emailSender, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(ForgotPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.Data.Email); + + if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) + { + return new ForgotPasswordResponse { Message = "If your email is registered, you will receive a reset link." }; + } + + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/reset-password?userId={user.Id}&code={code}"; + + var message = $@" +
+

Password Reset Request

+

Hello, {user.FullName}!

+

We received a request to reset your password. Click the link below to proceed:

+

Reset Password

+
+

If you didn't request this, you can safely ignore this email.

+

{callbackUrl}

+
"; + + await _emailSender.SendPasswordResetLinkAsync(user, user.Email!, message); + + return new ForgotPasswordResponse { Message = "Reset link has been sent." }; + } +} \ No newline at end of file diff --git a/Features/Account/Login/Components/LoginPage.razor b/Features/Account/Login/Components/LoginPage.razor new file mode 100644 index 0000000..ece2443 --- /dev/null +++ b/Features/Account/Login/Components/LoginPage.razor @@ -0,0 +1,451 @@ +@page "/account/login" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.Extensions.Options +@inject IJSRuntime JSRuntime +@inject IOptions IdentityOptions +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +Sign In - @GlobalConsts.AppInitial + + + + + + + +
+
+ + +
+ +
+
+ + Forgot password? +
+ +
+ +
+ +
+ + +
+ + @if (IdentityOptions.Value.SsoFirebase.IsUsed) + { +
Or continue with
+ + + } +
+ + + +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + private bool rememberMe; + private LoginModel model = new(); + + private async Task ValidateForm(EditContext context) => await Task.FromResult(true); + + private async Task HandleLogin() + { + await form.Validate(); + if (!success) return; + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountSignIn", model.Email, model.Password, rememberMe); + ProcessLoginResult(result); + } + + private async Task HandleFirebaseGoogleLogin() + { + isProcessing = true; + StateHasChanged(); + + var firebaseUser = await JSRuntime.InvokeAsync("signInWithGoogle"); + + if (firebaseUser == null || string.IsNullOrEmpty(firebaseUser.Email)) + { + Snackbar.Add("Google Sign-In failed or cancelled.", Severity.Error); + isProcessing = false; + return; + } + + var checkResult = await JSRuntime.InvokeAsync("apiCheckActiveUser", firebaseUser.Email); + bool openForPublic = IdentityOptions.Value.SsoFirebase.OpenForPublic; + + if (checkResult.Exists) + { + if (checkResult.IsActive) + { + await ExecuteSsoSignIn(firebaseUser.Email); + } + else + { + Snackbar.Add("Account is registered but not active.", Severity.Warning); + isProcessing = false; + } + } + else + { + var registerResult = await JSRuntime.InvokeAsync( + "apiAccountSignUpSso", + firebaseUser.Email, + firebaseUser.Email, + openForPublic + ); + + if (registerResult.Status == 200) + { + if (openForPublic) + { + await ExecuteSsoSignIn(firebaseUser.Email); + } + else + { + Snackbar.Add("Registration successful. Please wait for admin approval.", Severity.Info); + isProcessing = false; + } + } + else + { + Snackbar.Add("Failed to auto-register account.", Severity.Error); + isProcessing = false; + } + } + } + + private async Task ExecuteSsoSignIn(string email) + { + var result = await JSRuntime.InvokeAsync("apiAccountSsoSignIn", email); + ProcessLoginResult(result); + } + + private void ProcessLoginResult(ApiJSRuntimeResponse result) + { + if (result.Status == 200) + { + Snackbar.Add(result.Title ?? "Login successful!", Severity.Success); + Task.Run(async () => + { + await Task.Delay(500); + Navigation.NavigateTo("/home", forceLoad: true); + }); + } + else + { + Snackbar.Add(result.Title ?? "Sign in failed.", Severity.Error); + isProcessing = false; + } + StateHasChanged(); + } + + private class ApiJSRuntimeResponse { public int? Status { get; set; } public string? Title { get; set; } public string? Message { get; set; } } + private class FirebaseUserResponse { public string? Email { get; set; } } + private class ActiveCheckResponse { public bool Exists { get; set; } public bool IsActive { get; set; } } + private class LoginModel { public string Email { get; set; } = ""; public string Password { get; set; } = ""; } +} + + \ No newline at end of file diff --git a/Features/Account/Login/Cqrs/LoginHandler.cs b/Features/Account/Login/Cqrs/LoginHandler.cs new file mode 100644 index 0000000..22609d9 --- /dev/null +++ b/Features/Account/Login/Cqrs/LoginHandler.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigFrontEnd.Service; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Account.Login.Cqrs; + +public class LoginRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool RememberMe { get; set; } +} + +public class LoginResponse +{ + public bool Succeeded { get; set; } + public string Message { get; set; } = string.Empty; + public bool IsNotAllowed { get; set; } + public string? Token { get; set; } + public string? RefreshToken { get; set; } +} + +public record LoginCommand(LoginRequest Data) : IRequest; + +public class LoginHandler : IRequestHandler +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly JwtSettingsModel _jwtSettings; + + public LoginHandler( + SignInManager signInManager, + UserManager userManager, + IOptions jwtSettings) + { + _signInManager = signInManager; + _userManager = userManager; + _jwtSettings = jwtSettings.Value; + } + + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.Data.Email); + if (user == null) return new LoginResponse { Succeeded = false, Message = "Invalid credentials" }; + + var result = await _signInManager.PasswordSignInAsync(user, request.Data.Password, request.Data.RememberMe, false); + + if (result.Succeeded) + { + var token = JwtService.GenerateNewJwt(user, _jwtSettings); + + var refreshToken = Guid.NewGuid().ToString().Replace("-", ""); + + user.RefreshToken = refreshToken; + user.LastLoginAt = DateTime.Now; + await _userManager.UpdateAsync(user); + + return new LoginResponse + { + Succeeded = true, + Message = "Login successful", + Token = token, + RefreshToken = refreshToken + }; + } + + return new LoginResponse { Succeeded = false, Message = "Invalid credentials" }; + } + + +} \ No newline at end of file diff --git a/Features/Account/Logout/LogoutPage.razor b/Features/Account/Logout/LogoutPage.razor new file mode 100644 index 0000000..7a211a3 --- /dev/null +++ b/Features/Account/Logout/LogoutPage.razor @@ -0,0 +1,212 @@ +@page "/account/logout" +@layout AuthenticationLayout + +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +Sign Out - @GlobalConsts.AppInitial + + + +
+
+
+ + + +
+
+ +

Sign Out

+

Are you sure you want to sign out of your account?

+ + + + + Cancel + +
+ +@code { + private bool isLoggingOut; + + private async Task Logout() + { + isLoggingOut = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountLogout"); + + if (result.Status == 200) + { + Snackbar.Add("Successfully signed out!", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/", forceLoad: true); + } + else + { + Snackbar.Add(result.Title ?? "Failed to logout.", Severity.Error); + await Task.Delay(500); + } + + isLoggingOut = false; + StateHasChanged(); + } + + private class ApiJSRuntimeResponse + { + public int? Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/Register/Components/RegisterPage.razor b/Features/Account/Register/Components/RegisterPage.razor new file mode 100644 index 0000000..6e774ea --- /dev/null +++ b/Features/Account/Register/Components/RegisterPage.razor @@ -0,0 +1,280 @@ +@page "/account/register" +@layout AuthenticationLayout + +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject IConfiguration Configuration + +Sign Up - @GlobalConsts.AppInitial + + + + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + + +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + + private RegisterModel model = new(); + + private class RegisterModel + { + public string FullName { get; set; } = ""; + public string Email { get; set; } = ""; + public string Password { get; set; } = ""; + } + + private async Task ValidateForm(EditContext context) + { + return await Task.FromResult(true); + } + + private async Task HandleRegister() + { + await form.Validate(); + + if (!success) return; + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountRegister", model.FullName, model.Email, model.Password); + + if (result.Status == 200) + { + var requireConfirmation = Configuration.GetValue("IdentitySettings:SignIn:RequireConfirmedAccount"); + + if (requireConfirmation) + { + Snackbar.Add("Registration successful! Please check your email to confirm your account.", Severity.Info); + } + else + { + Snackbar.Add("Registration successful! You can now sign in.", Severity.Success); + } + + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Registration failed", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiJSRuntimeResponse + { + public int? Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/Register/Cqrs/RegisterHandler.cs b/Features/Account/Register/Cqrs/RegisterHandler.cs new file mode 100644 index 0000000..57d6ecd --- /dev/null +++ b/Features/Account/Register/Cqrs/RegisterHandler.cs @@ -0,0 +1,106 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authorization.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Account.Register.Cqrs; + +public class CreateUserRequest +{ + public string FullName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public bool EmailConfirmed { get; set; } = false; +} + +public class CreateUserResponse +{ + public string? Id { get; set; } + public string? Email { get; set; } +} + +public record CreateUserCommand(CreateUserRequest Data) : IRequest; + +public class RegisterHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IConfiguration _configuration; + private readonly IHttpContextAccessor _httpContextAccessor; + + public RegisterHandler( + UserManager userManager, + IEmailSender emailSender, + IConfiguration configuration, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _configuration = configuration; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var user = new ApplicationUser + { + UserName = request.Data.Email, + Email = request.Data.Email, + FullName = request.Data.FullName, + IsActive = request.Data.IsActive, + EmailConfirmed = request.Data.EmailConfirmed, + }; + + var result = await _userManager.CreateAsync(user, request.Data.Password); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(x => x.Description).ToList(); + throw new Exception(string.Join(", ", errors)); + } + + var rolesToAdd = ApplicationRoles.AllRoles + .Where(role => role != ApplicationRoles.Admin) + .ToList(); + + if (rolesToAdd.Any()) + { + await _userManager.AddToRolesAsync(user, rolesToAdd); + } + + var requireConfirmation = _configuration.GetValue("IdentitySettings:SignIn:RequireConfirmedAccount"); + + if (requireConfirmation) + { + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/confirm-email?userId={user.Id}&code={code}"; + + var message = $@" +
+

Welcome, {user.FullName}!

+

Please confirm your account by clicking the link below:

+

Confirm Account

+
+

If the link doesn't work, copy and paste this URL into your browser:

+

{callbackUrl}

+
"; + + await _emailSender.SendConfirmationLinkAsync(user, user.Email!, message); + } + + return new CreateUserResponse + { + Id = user.Id, + Email = user.Email + }; + } +} \ No newline at end of file diff --git a/Features/Account/ResetPassword/Components/ResetPasswordPage.razor b/Features/Account/ResetPassword/Components/ResetPasswordPage.razor new file mode 100644 index 0000000..b79358f --- /dev/null +++ b/Features/Account/ResetPassword/Components/ResetPasswordPage.razor @@ -0,0 +1,221 @@ +@page "/account/reset-password" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + +Reset Password - @GlobalConsts.AppInitial + + + + + + + +
+
+ + +
+ +
+ + +
+ + +
+
+ +@code { + [SupplyParameterFromQuery] public string? userId { get; set; } + [SupplyParameterFromQuery] public string? code { get; set; } + + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + private ResetModel model = new(); + + private class ResetModel + { + public string NewPassword { get; set; } = ""; + public string ConfirmPassword { get; set; } = ""; + } + + private string? PasswordMatch(string arg) + { + if (model.NewPassword != arg) return "Passwords do not match"; + return null; + } + + private async Task HandleResetPassword() + { + await form.Validate(); + if (!success) return; + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code)) + { + Snackbar.Add("Invalid reset token or user ID.", Severity.Error); + return; + } + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiResetPassword", userId, code, model.NewPassword); + + if (result.Status == 200) + { + Snackbar.Add("Password reset successful! You can now sign in.", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Failed to reset password", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiResponse + { + public int Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs b/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs new file mode 100644 index 0000000..0619619 --- /dev/null +++ b/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Account.ResetPassword.Cqrs; + +public class ResetPasswordRequest +{ + public string UserId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class ResetPasswordResponse +{ + public string Message { get; set; } = string.Empty; +} + +public record ResetPasswordCommand(ResetPasswordRequest Data) : IRequest; + +public class ResetPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ResetPasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ResetPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) + { + throw new Exception("User not found."); + } + + var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Data.Code)); + var result = await _userManager.ResetPasswordAsync(user, decodedCode, request.Data.NewPassword); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(x => x.Description).ToList(); + throw new Exception(string.Join(", ", errors)); + } + + return new ResetPasswordResponse { Message = "Password has been reset successfully." }; + } +} \ No newline at end of file diff --git a/Features/Account/_Imports.razor b/Features/Account/_Imports.razor new file mode 100644 index 0000000..a68bf17 --- /dev/null +++ b/Features/Account/_Imports.razor @@ -0,0 +1,7 @@ +@using MudBlazor +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using System.ComponentModel.DataAnnotations + +@inject NavigationManager Navigation +@inject ISnackbar Snackbar \ No newline at end of file diff --git a/Features/AppSettings/Json/JsonPage.razor b/Features/AppSettings/Json/JsonPage.razor new file mode 100644 index 0000000..2421b9a --- /dev/null +++ b/Features/AppSettings/Json/JsonPage.razor @@ -0,0 +1,259 @@ +@page "/appsettings/json" +@using Indotalent.Infrastructure.Authentication.Firebase +@using Indotalent.Infrastructure.Authentication.Keycloak +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.Admin)] +@using Indotalent.Infrastructure.Authentication.Identity +@using Indotalent.Infrastructure.Database +@using Indotalent.Infrastructure.BackgroundJob +@using Indotalent.Infrastructure.Email +@using Indotalent.Infrastructure.File +@using Indotalent.Infrastructure.Logging +@using Indotalent.Infrastructure.Authentication +@using MudBlazor +@using System.Reflection +@inject IdentityService IdentitySvc +@inject DatabaseService DbSvc +@inject BackgroundJobService JobSvc +@inject EmailService EmailSvc +@inject FileStorageService StorageSvc +@inject LoggingService LogSvc +@inject FirebaseService FirebaseSvc +@inject KeycloakService KeycloakSvc + + + + +
+ + + + @RenderIdentityProperties(IdentitySvc.GetConfiguration(), "Security & Identity") + + + + @RenderObjectProperties(DbSvc.GetConfiguration(), "Database Engine") + + + + @RenderObjectProperties(JobSvc.GetConfiguration(), "Background Job Services") + + + + @RenderObjectProperties(EmailSvc.GetConfiguration(), "Email Providers") + + + + @RenderObjectProperties(StorageSvc.GetConfiguration(), "File Storage Systems") + + + + @RenderObjectProperties(LogSvc.GetConfiguration(), "Diagnostic Logging") + + + +
+
+ +@code { + private RenderFragment RenderIdentityProperties(IdentitySettingsModel settings, string sectionTitle) + { + return __builder => + { + +
+ @sectionTitle + Security Policy & Default Credentials +
+
+ + / + System + / + @sectionTitle +
+
+ + if (settings != null) + { + @RenderSectionCard("Password Policy", settings.Password) + @RenderSectionCard("Cookie Settings", settings.Cookies) + @RenderSectionCard("Default Admin", settings.DefaultAdmin) + @RenderSectionCard("SignIn Policy", settings.SignIn) + @RenderSectionCard("SSO Firebase", settings.SsoFirebase) + @RenderSectionCard("SSO Keycloak", settings.SsoKeycloak) + } + }; + } + + private RenderFragment RenderSectionCard(string title, object sectionValue) + { + return __builder => + { + if (sectionValue == null) return; + + +
+ @title + CONFIGURED +
+ + @{ + var props = sectionValue.GetType().GetProperties(); + int i = 0; + } + @foreach (var p in props) + { + var val = p.GetValue(sectionValue); +
+
@p.Name
+
+ @(MaskIfSensitive(p.Name, val?.ToString())) +
+
+ } +
+
+ }; + } + + private RenderFragment RenderObjectProperties(object settingsObj, string sectionTitle) + { + return __builder => + { + +
+ @sectionTitle + Configuration discovery (Full Inspection Mode) +
+
+ + / + System + / + @sectionTitle +
+
+ + if (settingsObj != null) + { + var providers = settingsObj.GetType().GetProperties(); + foreach (var provider in providers) + { + var providerValue = provider.GetValue(settingsObj); + if (providerValue == null) continue; + + +
+ @provider.Name + @{ + var isUsedProp = providerValue.GetType().GetProperty("IsUsed"); + bool isUsed = (bool)(isUsedProp?.GetValue(providerValue) ?? false); + } + + @(isUsed ? "ACTIVE" : "DISABLED") + +
+ + @{ + var props = providerValue.GetType().GetProperties(); + int i = 0; + } + @foreach (var p in props) + { + if (p.Name == "IsUsed") continue; +
+
@p.Name
+
+ @(MaskIfSensitive(p.Name, p.GetValue(providerValue)?.ToString())) +
+
+ } +
+
+ } + } + }; + } + + private string MaskIfSensitive(string propertyName, string value) + { + if (string.IsNullOrEmpty(value)) return "-"; + var sensitiveKeys = new[] { "Password", "Secret", "Key", "Token", "ApiKey" }; + if (sensitiveKeys.Any(k => propertyName.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + return "•••••••••••••••• (Encrypted)"; + } + return value; + } +} \ No newline at end of file diff --git a/Features/FeaturesDI.cs b/Features/FeaturesDI.cs new file mode 100644 index 0000000..27bfc0a --- /dev/null +++ b/Features/FeaturesDI.cs @@ -0,0 +1,111 @@ +using Indotalent.Features.Inventory.Product; +using Indotalent.Features.Inventory.ProductGroup; +using Indotalent.Features.Inventory.UnitMeasure; +using Indotalent.Features.Inventory.Warehouse; +using Indotalent.Features.Profile.Avatar; +using Indotalent.Features.Profile.Password; +using Indotalent.Features.Profile.PersonalInformation; +using Indotalent.Features.Purchase.Bill; +using Indotalent.Features.Purchase.BillReport; +using Indotalent.Features.Purchase.PaymentDisburse; +using Indotalent.Features.Purchase.PaymentDisburseReport; +using Indotalent.Features.Purchase.PurchaseOrder; +using Indotalent.Features.Purchase.PurchaseReport; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Sales.Invoice; +using Indotalent.Features.Sales.InvoiceReport; +using Indotalent.Features.Sales.PaymentReceive; +using Indotalent.Features.Sales.PaymentReceiveReport; +using Indotalent.Features.Sales.SalesOrder; +using Indotalent.Features.Sales.SalesReport; +using Indotalent.Features.Serilogs; +using Indotalent.Features.Setting.AutoNumberSequence; +using Indotalent.Features.Setting.Company; +using Indotalent.Features.Setting.Currency; +using Indotalent.Features.Setting.SystemUser; +using Indotalent.Features.Setting.Tax; +using Indotalent.Features.Thirdparty.Customer; +using Indotalent.Features.Thirdparty.CustomerCategory; +using Indotalent.Features.Thirdparty.CustomerContact; +using Indotalent.Features.Thirdparty.CustomerGroup; +using Indotalent.Features.Thirdparty.Vendor; +using Indotalent.Features.Thirdparty.VendorCategory; +using Indotalent.Features.Thirdparty.VendorContact; +using Indotalent.Features.Thirdparty.VendorGroup; +using Indotalent.Features.Utilities.BookingGroup; +using Indotalent.Features.Utilities.BookingManager; +using Indotalent.Features.Utilities.BookingResource; +using Indotalent.Features.Utilities.BookingScheduler; +using Indotalent.Features.Utilities.ProgramManager; +using Indotalent.Features.Utilities.ProgramResource; +using Indotalent.Features.Utilities.Todo; + +namespace Indotalent.Features; + +public static class FeaturesDI +{ + public static IServiceCollection AddFeaturesDI(this IServiceCollection services) + { + //Setting + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //profile + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + + //root + services.AddScoped(); + + //inventory + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //thridparty + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + + //utilities + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //purchase + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //sales + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + + return services; + } +} diff --git a/Features/FeaturesEndpointMap.cs b/Features/FeaturesEndpointMap.cs new file mode 100644 index 0000000..9399979 --- /dev/null +++ b/Features/FeaturesEndpointMap.cs @@ -0,0 +1,109 @@ +using Indotalent.Features.Inventory.Product; +using Indotalent.Features.Inventory.ProductGroup; +using Indotalent.Features.Inventory.UnitMeasure; +using Indotalent.Features.Inventory.Warehouse; +using Indotalent.Features.Profile.Avatar; +using Indotalent.Features.Profile.Password; +using Indotalent.Features.Profile.PersonalInformation; +using Indotalent.Features.Purchase.Bill; +using Indotalent.Features.Purchase.BillReport; +using Indotalent.Features.Purchase.PaymentDisburse; +using Indotalent.Features.Purchase.PaymentDisburseReport; +using Indotalent.Features.Purchase.PurchaseOrder; +using Indotalent.Features.Purchase.PurchaseReport; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Sales.Invoice; +using Indotalent.Features.Sales.InvoiceReport; +using Indotalent.Features.Sales.PaymentReceive; +using Indotalent.Features.Sales.PaymentReceiveReport; +using Indotalent.Features.Sales.SalesOrder; +using Indotalent.Features.Sales.SalesReport; +using Indotalent.Features.Serilogs; +using Indotalent.Features.Setting.AutoNumberSequence; +using Indotalent.Features.Setting.Company; +using Indotalent.Features.Setting.Currency; +using Indotalent.Features.Setting.SystemUser; +using Indotalent.Features.Setting.Tax; +using Indotalent.Features.Thirdparty.Customer; +using Indotalent.Features.Thirdparty.CustomerCategory; +using Indotalent.Features.Thirdparty.CustomerContact; +using Indotalent.Features.Thirdparty.CustomerGroup; +using Indotalent.Features.Thirdparty.Vendor; +using Indotalent.Features.Thirdparty.VendorCategory; +using Indotalent.Features.Thirdparty.VendorContact; +using Indotalent.Features.Thirdparty.VendorGroup; +using Indotalent.Features.Utilities.BookingGroup; +using Indotalent.Features.Utilities.BookingManager; +using Indotalent.Features.Utilities.BookingResource; +using Indotalent.Features.Utilities.BookingScheduler; +using Indotalent.Features.Utilities.ProgramManager; +using Indotalent.Features.Utilities.ProgramResource; +using Indotalent.Features.Utilities.Todo; + +namespace Indotalent.Features; + +public static class FeaturesEndpointMap +{ + public static IEndpointRouteBuilder MapFeaturesEndpoint(this IEndpointRouteBuilder app) + { + //setting + app.MapSerilogsEndpoints(); + app.MapCurrencyEndpoints(); + app.MapAutoNumberSequenceEndpoints(); + app.MapSystemUserEndpoints(); + app.MapCompanyEndpoints(); + app.MapTaxEndpoints(); + + //profile + app.MapPersonalInformationEndpoints(); + app.MapPasswordEndpoints(); + app.MapAvatarEndpoints(); + + //root + app.MapHomeEndpoints(); + + //inventory + app.MapUnitMeasureEndpoints(); + app.MapProductGroupEndpoints(); + app.MapWarehouseEndpoints(); + app.MapProductEndpoints(); + + //thirdparty + app.MapCustomerGroupEndpoints(); + app.MapCustomerCategoryEndpoints(); + app.MapCustomerEndpoints(); + app.MapCustomerContactEndpoints(); + app.MapVendorCategoryEndpoints(); + app.MapVendorGroupEndpoints(); + app.MapVendorEndpoints(); + app.MapVendorContactEndpoints(); + + + //utilities + app.MapTodoEndpoints(); + app.MapBookingGroupEndpoints(); + app.MapBookingResourceEndpoints(); + app.MapBookingManagerEndpoints(); + app.MapBookingSchedulerEndpoints(); + app.MapProgramResourceEndpoints(); + app.MapProgramManagerEndpoints(); + + //purchase + app.MapPurchaseOrderEndpoints(); + app.MapPurchaseReportEndpoints(); + app.MapBillReportEndpoints(); + app.MapPaymentDisburseReportEndpoints(); + app.MapBillEndpoints(); + app.MapPaymentDisburseEndpoints(); + + //sales + app.MapSalesOrderEndpoints(); + app.MapSalesReportEndpoints(); + app.MapInvoiceReportEndpoints(); + app.MapPaymentReceiveReportEndpoints(); + app.MapInvoiceEndpoints(); + app.MapPaymentReceiveEndpoints(); + + return app; + } +} diff --git a/Features/Inventory/InventoryPage.razor b/Features/Inventory/InventoryPage.razor new file mode 100644 index 0000000..9966559 --- /dev/null +++ b/Features/Inventory/InventoryPage.razor @@ -0,0 +1,101 @@ +@page "/inventory" +@using Indotalent.Features.Inventory.Product.Components +@using Indotalent.Features.Inventory.ProductGroup.Components +@using Indotalent.Features.Inventory.UnitMeasure.Components +@using Indotalent.Features.Inventory.Warehouse.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "unit-measure" }, + { 1, "product-group" }, + { 2, "product" }, + { 3, "warehouse" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + + _activeTabIndex = match.Value != null ? match.Key : 0; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/inventory?tab={tabName ?? "unit-measure"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Components/ProductPage.razor b/Features/Inventory/Product/Components/ProductPage.razor new file mode 100644 index 0000000..0650fff --- /dev/null +++ b/Features/Inventory/Product/Components/ProductPage.razor @@ -0,0 +1,28 @@ +@page "/inventory/product" +@using Indotalent.Features.Inventory.Product +@using Indotalent.Features.Inventory.Product.Cqrs +@using Indotalent.Features.Inventory.Product.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ProductCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ProductUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_ProductDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateProductRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateProductRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Components/_ProductCreateForm.razor b/Features/Inventory/Product/Components/_ProductCreateForm.razor new file mode 100644 index 0000000..ab9bda4 --- /dev/null +++ b/Features/Inventory/Product/Components/_ProductCreateForm.razor @@ -0,0 +1,114 @@ +@using Indotalent.Features.Inventory.Product +@using Indotalent.Features.Inventory.Product.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProductService ProductService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Product + Define item specification, pricing, and category. +
+
+
+ + + + + + Product Name + + + + Unit Price + + + + Product Group + + @foreach (var item in _lookupData.ProductGroups) + { + @item.Name + } + + + + Unit of Measure + + @foreach (var item in _lookupData.UnitMeasures) + { + @item.Name + } + + + + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Product + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateProductValidator _validator = new(); + private CreateProductRequest _model = new(); + private LookupProductResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await ProductService.GetProductLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await ProductService.CreateProductAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Product created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Components/_ProductDataTable.razor b/Features/Inventory/Product/Components/_ProductDataTable.razor new file mode 100644 index 0000000..7d6d1f1 --- /dev/null +++ b/Features/Inventory/Product/Components/_ProductDataTable.razor @@ -0,0 +1,298 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Product +@using Indotalent.Features.Inventory.Product.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ProductService ProductService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Product Management + Manage your goods, services, and inventory items. +
+
+ + / + Inventory + / + Product +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedProduct != null) + { + View + Edit + Remove + + } + else + { + + Add New Product + + } +
+
+ + + + + + Product + + + Group + + + UoM + + + Unit Price + + Type + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.ProductGroupName + @context.UnitMeasureName + @context.UnitPrice?.ToString("N2") + + + @(context.Physical == true ? "PHYSICAL" : "SERVICE") + + +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _products = new(); + private GetProductListResponse? _selectedProduct; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedProduct = null; + StateHasChanged(); + try + { + var response = await ProductService.GetProductListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _products = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _products; + return _products.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedProduct = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Products"); + worksheet.Cell(1, 1).Value = "Product Name"; + worksheet.Cell(1, 2).Value = "Number"; + worksheet.Cell(1, 3).Value = "Group"; + worksheet.Cell(1, 4).Value = "UoM"; + worksheet.Cell(1, 5).Value = "Unit Price"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.AutoNumber; + worksheet.Cell(currentRow, 3).Value = item.ProductGroupName; + worksheet.Cell(currentRow, 4).Value = item.UnitMeasureName; + worksheet.Cell(currentRow, 5).Value = item.UnitPrice; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Product_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedProduct = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedProduct = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedProduct != null) { var req = await MapToUpdateRequest(_selectedProduct.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedProduct != null) { var req = await MapToUpdateRequest(_selectedProduct.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await ProductService.GetProductByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateProductRequest { Id = d.Id, Name = d.Name, Description = d.Description, UnitPrice = d.UnitPrice, Physical = d.Physical, UnitMeasureId = d.UnitMeasureId, ProductGroupId = d.ProductGroupId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedProduct == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedProduct.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await ProductService.DeleteProductByIdAsync(_selectedProduct.Id); + if (isSuccess) { _selectedProduct = null; await LoadData(); Snackbar.Add("Product deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Components/_ProductUpdateForm.razor b/Features/Inventory/Product/Components/_ProductUpdateForm.razor new file mode 100644 index 0000000..4c9c4c8 --- /dev/null +++ b/Features/Inventory/Product/Components/_ProductUpdateForm.razor @@ -0,0 +1,187 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Product +@using Indotalent.Features.Inventory.Product.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProductService ProductService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Product Details" : "Edit Product") + @(ReadOnly ? "Viewing product specification." : "Modify existing product information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ +
+ } + else + { + + + + Product Name + + + + Unit Price + + + + Product Group + + @foreach (var item in _lookupData.ProductGroups) + { + @item.Name + } + + + + Unit of Measure + + @foreach (var item in _lookupData.UnitMeasures) + { + @item.Name + } + + + + + + + Description + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateProductRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateProductValidator _validator = new(); + private UpdateProductRequest _model = new(); + private LookupProductResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await ProductService.GetProductLookupAsync(); + if (response != null && response.IsSuccess) + { + _lookupData = response.Value!; + } + + _model = new UpdateProductRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + UnitPrice = Data.UnitPrice, + Physical = Data.Physical, + UnitMeasureId = Data.UnitMeasureId, + ProductGroupId = Data.ProductGroupId, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + finally + { + _isDataLoading = false; + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await ProductService.UpdateProductAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Product updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/CreateProductHandler.cs b/Features/Inventory/Product/Cqrs/CreateProductHandler.cs new file mode 100644 index 0000000..bbb2018 --- /dev/null +++ b/Features/Inventory/Product/Cqrs/CreateProductHandler.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class CreateProductRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public decimal? UnitPrice { get; set; } + public bool? Physical { get; set; } = true; + public string? UnitMeasureId { get; set; } + public string? ProductGroupId { get; set; } +} + +public class CreateProductResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateProductCommand(CreateProductRequest Data) : IRequest; + +public class CreateProductHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateProductHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Product + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Product", request.Data.Name ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Product); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Product + { + AutoNumber = autoNo, + Name = request.Data.Name, + Description = request.Data.Description, + UnitPrice = request.Data.UnitPrice, + Physical = request.Data.Physical, + UnitMeasureId = request.Data.UnitMeasureId, + ProductGroupId = request.Data.ProductGroupId + }; + + _context.Product.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateProductResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/CreateProductValidator.cs b/Features/Inventory/Product/Cqrs/CreateProductValidator.cs new file mode 100644 index 0000000..60d9691 --- /dev/null +++ b/Features/Inventory/Product/Cqrs/CreateProductValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class CreateProductValidator : AbstractValidator +{ + public CreateProductValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Product Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.UnitMeasureId) + .NotEmpty().WithMessage("Unit of Measure is required"); + + RuleFor(x => x.ProductGroupId) + .NotEmpty().WithMessage("Product Group is required"); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0).WithMessage("Unit Price cannot be negative"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/DeleteProductByIdHandler.cs b/Features/Inventory/Product/Cqrs/DeleteProductByIdHandler.cs new file mode 100644 index 0000000..43a4df5 --- /dev/null +++ b/Features/Inventory/Product/Cqrs/DeleteProductByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public record DeleteProductByIdRequest(string Id); + +public record DeleteProductByIdCommand(DeleteProductByIdRequest Data) : IRequest; + +public class DeleteProductByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteProductByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteProductByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Product + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Product.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/GetProductByIdHandler.cs b/Features/Inventory/Product/Cqrs/GetProductByIdHandler.cs new file mode 100644 index 0000000..1b66e8c --- /dev/null +++ b/Features/Inventory/Product/Cqrs/GetProductByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class GetProductByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal? UnitPrice { get; set; } + public bool? Physical { get; set; } + public string? UnitMeasureId { get; set; } + public string? ProductGroupId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProductByIdQuery(string Id) : IRequest; + +public class GetProductByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProductByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProductByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Product + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProductByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + Description = x.Description, + UnitPrice = x.UnitPrice, + Physical = x.Physical, + UnitMeasureId = x.UnitMeasureId, + ProductGroupId = x.ProductGroupId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/GetProductListHandler.cs b/Features/Inventory/Product/Cqrs/GetProductListHandler.cs new file mode 100644 index 0000000..e15b7ae --- /dev/null +++ b/Features/Inventory/Product/Cqrs/GetProductListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class GetProductListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public decimal? UnitPrice { get; set; } + public bool? Physical { get; set; } + public string? UnitMeasureName { get; set; } + public string? ProductGroupName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } +} + +public record GetProductListQuery() : IRequest>; + +public class GetProductListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetProductListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetProductListQuery request, CancellationToken cancellationToken) + { + return await _context.Product + .AsNoTracking() + .Include(x => x.UnitMeasure) + .Include(x => x.ProductGroup) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetProductListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + UnitPrice = x.UnitPrice, + Physical = x.Physical, + UnitMeasureName = x.UnitMeasure != null ? x.UnitMeasure.Name : string.Empty, + ProductGroupName = x.ProductGroup != null ? x.ProductGroup.Name : string.Empty, + CreatedAt = x.CreatedAt + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/LookupProductHandler.cs b/Features/Inventory/Product/Cqrs/LookupProductHandler.cs new file mode 100644 index 0000000..48b70c9 --- /dev/null +++ b/Features/Inventory/Product/Cqrs/LookupProductHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class LookupProductResponse +{ + public List UnitMeasures { get; set; } = new(); + public List ProductGroups { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupProductQuery() : IRequest; + +public class LookupProductHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupProductHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupProductQuery request, CancellationToken cancellationToken) + { + var result = new LookupProductResponse(); + + result.UnitMeasures = await _context.UnitMeasure + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + result.ProductGroups = await _context.ProductGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/UpdateProductHandler.cs b/Features/Inventory/Product/Cqrs/UpdateProductHandler.cs new file mode 100644 index 0000000..eea9b2e --- /dev/null +++ b/Features/Inventory/Product/Cqrs/UpdateProductHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class UpdateProductRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal? UnitPrice { get; set; } + public bool? Physical { get; set; } + public string? UnitMeasureId { get; set; } + public string? ProductGroupId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateProductResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateProductCommand(UpdateProductRequest Data) : IRequest; + +public class UpdateProductHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProductHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Product + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Product", request.Data.Name ?? string.Empty); + } + + var entity = await _context.Product + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateProductResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.UnitPrice = request.Data.UnitPrice; + entity.Physical = request.Data.Physical; + entity.UnitMeasureId = request.Data.UnitMeasureId; + entity.ProductGroupId = request.Data.ProductGroupId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProductResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/Cqrs/UpdateProductValidator.cs b/Features/Inventory/Product/Cqrs/UpdateProductValidator.cs new file mode 100644 index 0000000..50f8996 --- /dev/null +++ b/Features/Inventory/Product/Cqrs/UpdateProductValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.Product.Cqrs; + +public class UpdateProductValidator : AbstractValidator +{ + public UpdateProductValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Product Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.UnitMeasureId) + .NotEmpty().WithMessage("Unit of Measure is required"); + + RuleFor(x => x.ProductGroupId) + .NotEmpty().WithMessage("Product Group is required"); + + RuleFor(x => x.UnitPrice) + .GreaterThanOrEqualTo(0).WithMessage("Unit Price cannot be negative"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/ProductEndpoint.cs b/Features/Inventory/Product/ProductEndpoint.cs new file mode 100644 index 0000000..81eb91c --- /dev/null +++ b/Features/Inventory/Product/ProductEndpoint.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.Product.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.Product; + +public static class ProductEndpoint +{ + public static void MapProductEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/product").WithTags("Products") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetProductListQuery()); + + return result.ToApiResponse("Product list retrieved successfully"); + }) + .WithName("GetProductList") + .WithTags("Products"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProductByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Product detail retrieved successfully" + : $"Product with ID {id} not found"); + }) + .WithName("GetProductById") + .WithTags("Products"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupProductQuery()); + + return result.ToApiResponse("Product lookup data retrieved successfully"); + }) + .WithName("GetProductLookup") + .WithTags("Products"); + + group.MapPost("/", async (CreateProductRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateProductCommand(request)); + + return result.ToApiResponse("Product has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateProduct") + .WithTags("Products"); + + group.MapPost("/update", async (UpdateProductRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProductCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The product data could not be found."); + } + + return result.ToApiResponse("Product has been updated successfully"); + }) + .WithName("UpdateProduct") + .WithTags("Products"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteProductByIdCommand(new DeleteProductByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The product data could not be found."); + } + + return true.ToApiResponse("Product has been deleted successfully"); + }) + .WithName("DeleteProductById") + .WithTags("Products"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Product/ProductService.cs b/Features/Inventory/Product/ProductService.cs new file mode 100644 index 0000000..389bff0 --- /dev/null +++ b/Features/Inventory/Product/ProductService.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.Product.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.Product; + +public class ProductService : BaseService +{ + private readonly RestClient _client; + + public ProductService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetProductListAsync() + { + var request = new RestRequest("api/product", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetProductByIdAsync(string id) + { + var request = new RestRequest($"api/product/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetProductLookupAsync() + { + var request = new RestRequest("api/product/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateProductAsync(CreateProductRequest data) + { + var request = new RestRequest("api/product", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteProductByIdAsync(string id) + { + var request = new RestRequest($"api/product/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateProductAsync(UpdateProductRequest data) + { + var request = new RestRequest("api/product/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Components/ProductGroupPage.razor b/Features/Inventory/ProductGroup/Components/ProductGroupPage.razor new file mode 100644 index 0000000..266c268 --- /dev/null +++ b/Features/Inventory/ProductGroup/Components/ProductGroupPage.razor @@ -0,0 +1,53 @@ +@page "/inventory/product-group" +@using Indotalent.Features.Inventory.ProductGroup +@using Indotalent.Features.Inventory.ProductGroup.Cqrs +@using Indotalent.Features.Inventory.ProductGroup.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ProductGroupCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ProductGroupUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_ProductGroupDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateProductGroupRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateProductGroupRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Components/_ProductGroupCreateForm.razor b/Features/Inventory/ProductGroup/Components/_ProductGroupCreateForm.razor new file mode 100644 index 0000000..baa8931 --- /dev/null +++ b/Features/Inventory/ProductGroup/Components/_ProductGroupCreateForm.razor @@ -0,0 +1,97 @@ +@using Indotalent.Features.Inventory.ProductGroup +@using Indotalent.Features.Inventory.ProductGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProductGroupService ProductGroupService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Group + Create a new category for your products. +
+
+
+ + + + + + Group Name + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Group + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateProductGroupValidator _validator = new(); + private CreateProductGroupRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await ProductGroupService.CreateProductGroupAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Group created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Components/_ProductGroupDataTable.razor b/Features/Inventory/ProductGroup/Components/_ProductGroupDataTable.razor new file mode 100644 index 0000000..1a0fda7 --- /dev/null +++ b/Features/Inventory/ProductGroup/Components/_ProductGroupDataTable.razor @@ -0,0 +1,372 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.ProductGroup +@using Indotalent.Features.Inventory.ProductGroup.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ProductGroupService ProductGroupService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Product Group + Categorize and manage your product groupings. +
+
+ + / + Inventory + / + Product Group +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedGroup != null) + { + View + Edit + Remove + + } + else + { + + Add New Group + + } +
+
+ + + + + + Group Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _groups = new(); + private GetProductGroupListResponse? _selectedGroup; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedGroup = null; + StateHasChanged(); + try + { + var response = await ProductGroupService.GetProductGroupListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _groups = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _groups; + return _groups.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedGroup = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("ProductGroups"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Group Name"; + worksheet.Cell(currentRow, 2).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "ProductGroup_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedGroup = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedGroup = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedGroup == null) return; + var request = await MapToUpdateRequest(_selectedGroup.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedGroup == null) return; + var request = await MapToUpdateRequest(_selectedGroup.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await ProductGroupService.GetProductGroupByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateProductGroupRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedGroup == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedGroup.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await ProductGroupService.DeleteProductGroupByIdAsync(_selectedGroup.Id); + if (isSuccess) + { + _selectedGroup = null; + await LoadData(); + Snackbar.Add("Group deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Components/_ProductGroupUpdateForm.razor b/Features/Inventory/ProductGroup/Components/_ProductGroupUpdateForm.razor new file mode 100644 index 0000000..f06f9cd --- /dev/null +++ b/Features/Inventory/ProductGroup/Components/_ProductGroupUpdateForm.razor @@ -0,0 +1,143 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.ProductGroup +@using Indotalent.Features.Inventory.ProductGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProductGroupService ProductGroupService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Group Details" : "Edit Group") + @(ReadOnly ? "Viewing product group specification." : "Modify existing group information.") +
+
+
+ + + + + + Group Name + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateProductGroupRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateProductGroupValidator _validator = new(); + private UpdateProductGroupRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateProductGroupRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await ProductGroupService.UpdateProductGroupAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Group updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupHandler.cs b/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupHandler.cs new file mode 100644 index 0000000..e46bee0 --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class CreateProductGroupRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateProductGroupResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateProductGroupCommand(CreateProductGroupRequest Data) : IRequest; + +public class CreateProductGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateProductGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateProductGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.ProductGroup + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Product Group", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.ProductGroup + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.ProductGroup.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateProductGroupResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupValidator.cs b/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupValidator.cs new file mode 100644 index 0000000..c8b8977 --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/CreateProductGroupValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class CreateProductGroupValidator : AbstractValidator +{ + public CreateProductGroupValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/DeleteProductGroupByIdHandler.cs b/Features/Inventory/ProductGroup/Cqrs/DeleteProductGroupByIdHandler.cs new file mode 100644 index 0000000..c911dbf --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/DeleteProductGroupByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public record DeleteProductGroupByIdRequest(string Id); + +public record DeleteProductGroupByIdCommand(DeleteProductGroupByIdRequest Data) : IRequest; + +public class DeleteProductGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteProductGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteProductGroupByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.ProductGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.ProductGroup.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/GetProductGroupByIdHandler.cs b/Features/Inventory/ProductGroup/Cqrs/GetProductGroupByIdHandler.cs new file mode 100644 index 0000000..4379aa8 --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/GetProductGroupByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class GetProductGroupByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProductGroupByIdQuery(string Id) : IRequest; + +public class GetProductGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProductGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProductGroupByIdQuery request, CancellationToken cancellationToken) + { + return await _context.ProductGroup + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProductGroupByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/GetProductGroupListHandler.cs b/Features/Inventory/ProductGroup/Cqrs/GetProductGroupListHandler.cs new file mode 100644 index 0000000..987b545 --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/GetProductGroupListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class GetProductGroupListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProductGroupListQuery() : IRequest>; + +public class GetProductGroupListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetProductGroupListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetProductGroupListQuery request, CancellationToken cancellationToken) + { + return await _context.ProductGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetProductGroupListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupHandler.cs b/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupHandler.cs new file mode 100644 index 0000000..238c3be --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class UpdateProductGroupRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateProductGroupResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateProductGroupCommand(UpdateProductGroupRequest Data) : IRequest; + +public class UpdateProductGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProductGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProductGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.ProductGroup + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Product Group", request.Data.Name ?? string.Empty); + } + + var entity = await _context.ProductGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateProductGroupResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProductGroupResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupValidator.cs b/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupValidator.cs new file mode 100644 index 0000000..66948a5 --- /dev/null +++ b/Features/Inventory/ProductGroup/Cqrs/UpdateProductGroupValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.ProductGroup.Cqrs; + +public class UpdateProductGroupValidator : AbstractValidator +{ + public UpdateProductGroupValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/ProductGroupEndpoint.cs b/Features/Inventory/ProductGroup/ProductGroupEndpoint.cs new file mode 100644 index 0000000..eac97ee --- /dev/null +++ b/Features/Inventory/ProductGroup/ProductGroupEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.ProductGroup.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.ProductGroup; + +public static class ProductGroupEndpoint +{ + public static void MapProductGroupEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/product-group").WithTags("ProductGroups") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetProductGroupListQuery()); + + return result.ToApiResponse("Product group list retrieved successfully"); + }) + .WithName("GetProductGroupList") + .WithTags("ProductGroups"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProductGroupByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Product group detail retrieved successfully" + : $"Product group with ID {id} not found"); + }) + .WithName("GetProductGroupById") + .WithTags("ProductGroups"); + + group.MapPost("/", async (CreateProductGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateProductGroupCommand(request)); + + return result.ToApiResponse("Product group has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateProductGroup") + .WithTags("ProductGroups"); + + group.MapPost("/update", async (UpdateProductGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProductGroupCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The product group data could not be found."); + } + + return result.ToApiResponse("Product group has been updated successfully"); + }) + .WithName("UpdateProductGroup") + .WithTags("ProductGroups"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteProductGroupByIdCommand(new DeleteProductGroupByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The product group data could not be found."); + } + + return true.ToApiResponse("Product group has been deleted successfully"); + }) + .WithName("DeleteProductGroupById") + .WithTags("ProductGroups"); + } +} \ No newline at end of file diff --git a/Features/Inventory/ProductGroup/ProductGroupService.cs b/Features/Inventory/ProductGroup/ProductGroupService.cs new file mode 100644 index 0000000..74ebb99 --- /dev/null +++ b/Features/Inventory/ProductGroup/ProductGroupService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.ProductGroup.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.ProductGroup; + +public class ProductGroupService : BaseService +{ + private readonly RestClient _client; + + public ProductGroupService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetProductGroupListAsync() + { + var request = new RestRequest("api/product-group", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetProductGroupByIdAsync(string id) + { + var request = new RestRequest($"api/product-group/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateProductGroupAsync(CreateProductGroupRequest data) + { + var request = new RestRequest("api/product-group", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteProductGroupByIdAsync(string id) + { + var request = new RestRequest($"api/product-group/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateProductGroupAsync(UpdateProductGroupRequest data) + { + var request = new RestRequest("api/product-group/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Components/UnitMeasurePage.razor b/Features/Inventory/UnitMeasure/Components/UnitMeasurePage.razor new file mode 100644 index 0000000..cbebe2c --- /dev/null +++ b/Features/Inventory/UnitMeasure/Components/UnitMeasurePage.razor @@ -0,0 +1,53 @@ +@page "/inventory/unit-measure" +@using Indotalent.Features.Inventory.UnitMeasure +@using Indotalent.Features.Inventory.UnitMeasure.Cqrs +@using Indotalent.Features.Inventory.UnitMeasure.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_UnitMeasureCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_UnitMeasureUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_UnitMeasureDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateUnitMeasureRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateUnitMeasureRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Components/_UnitMeasureCreateForm.razor b/Features/Inventory/UnitMeasure/Components/_UnitMeasureCreateForm.razor new file mode 100644 index 0000000..a9d543c --- /dev/null +++ b/Features/Inventory/UnitMeasure/Components/_UnitMeasureCreateForm.razor @@ -0,0 +1,97 @@ +@using Indotalent.Features.Inventory.UnitMeasure +@using Indotalent.Features.Inventory.UnitMeasure.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject UnitMeasureService UnitMeasureService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Unit + Define measurement units for your products. +
+
+
+ + + + + + Unit Name + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Unit + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateUnitMeasureValidator _validator = new(); + private CreateUnitMeasureRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await UnitMeasureService.CreateUnitMeasureAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Unit created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Components/_UnitMeasureDataTable.razor b/Features/Inventory/UnitMeasure/Components/_UnitMeasureDataTable.razor new file mode 100644 index 0000000..3a4a522 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Components/_UnitMeasureDataTable.razor @@ -0,0 +1,372 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.UnitMeasure +@using Indotalent.Features.Inventory.UnitMeasure.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject UnitMeasureService UnitMeasureService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Unit of Measure + Manage measurement units for inventory items. +
+
+ + / + Inventory + / + Unit Measure +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedUnit != null) + { + View + Edit + Remove + + } + else + { + + Add New Unit + + } +
+
+ + + + + + Unit Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _units = new(); + private GetUnitMeasureListResponse? _selectedUnit; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedUnit = null; + StateHasChanged(); + try + { + var response = await UnitMeasureService.GetUnitMeasureListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _units = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _units; + return _units.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedUnit = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("UnitMeasures"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Unit Name"; + worksheet.Cell(currentRow, 2).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "UnitMeasure_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedUnit = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedUnit = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedUnit == null) return; + var request = await MapToUpdateRequest(_selectedUnit.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedUnit == null) return; + var request = await MapToUpdateRequest(_selectedUnit.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await UnitMeasureService.GetUnitMeasureByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateUnitMeasureRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedUnit == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedUnit.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await UnitMeasureService.DeleteUnitMeasureByIdAsync(_selectedUnit.Id); + if (isSuccess) + { + _selectedUnit = null; + await LoadData(); + Snackbar.Add("Unit deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Components/_UnitMeasureUpdateForm.razor b/Features/Inventory/UnitMeasure/Components/_UnitMeasureUpdateForm.razor new file mode 100644 index 0000000..b9f00d1 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Components/_UnitMeasureUpdateForm.razor @@ -0,0 +1,143 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.UnitMeasure +@using Indotalent.Features.Inventory.UnitMeasure.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject UnitMeasureService UnitMeasureService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Unit Details" : "Edit Unit") + @(ReadOnly ? "Viewing unit of measure specification." : "Modify existing unit information.") +
+
+
+ + + + + + Unit Name + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateUnitMeasureRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateUnitMeasureValidator _validator = new(); + private UpdateUnitMeasureRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateUnitMeasureRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await UnitMeasureService.UpdateUnitMeasureAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Unit updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureHandler.cs b/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureHandler.cs new file mode 100644 index 0000000..c9389c8 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class CreateUnitMeasureRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateUnitMeasureResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateUnitMeasureCommand(CreateUnitMeasureRequest Data) : IRequest; + +public class CreateUnitMeasureHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateUnitMeasureHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateUnitMeasureCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.UnitMeasure + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Unit of Measure", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.UnitMeasure + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.UnitMeasure.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateUnitMeasureResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureValidator.cs b/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureValidator.cs new file mode 100644 index 0000000..ea2f0e0 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/CreateUnitMeasureValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class CreateUnitMeasureValidator : AbstractValidator +{ + public CreateUnitMeasureValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Unit Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/DeleteUnitMeasureByIdHandler.cs b/Features/Inventory/UnitMeasure/Cqrs/DeleteUnitMeasureByIdHandler.cs new file mode 100644 index 0000000..788aa43 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/DeleteUnitMeasureByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public record DeleteUnitMeasureByIdRequest(string Id); + +public record DeleteUnitMeasureByIdCommand(DeleteUnitMeasureByIdRequest Data) : IRequest; + +public class DeleteUnitMeasureByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteUnitMeasureByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteUnitMeasureByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.UnitMeasure + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.UnitMeasure.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureByIdHandler.cs b/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureByIdHandler.cs new file mode 100644 index 0000000..49cfc7a --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class GetUnitMeasureByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetUnitMeasureByIdQuery(string Id) : IRequest; + +public class GetUnitMeasureByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetUnitMeasureByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetUnitMeasureByIdQuery request, CancellationToken cancellationToken) + { + return await _context.UnitMeasure + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetUnitMeasureByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureListHandler.cs b/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureListHandler.cs new file mode 100644 index 0000000..3a9455c --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/GetUnitMeasureListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class GetUnitMeasureListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetUnitMeasureListQuery() : IRequest>; + +public class GetUnitMeasureListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetUnitMeasureListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetUnitMeasureListQuery request, CancellationToken cancellationToken) + { + return await _context.UnitMeasure + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetUnitMeasureListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureHandler.cs b/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureHandler.cs new file mode 100644 index 0000000..3ff8048 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class UpdateUnitMeasureRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateUnitMeasureResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateUnitMeasureCommand(UpdateUnitMeasureRequest Data) : IRequest; + +public class UpdateUnitMeasureHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateUnitMeasureHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateUnitMeasureCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.UnitMeasure + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Unit of Measure", request.Data.Name ?? string.Empty); + } + + var entity = await _context.UnitMeasure + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateUnitMeasureResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateUnitMeasureResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureValidator.cs b/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureValidator.cs new file mode 100644 index 0000000..edb0c41 --- /dev/null +++ b/Features/Inventory/UnitMeasure/Cqrs/UpdateUnitMeasureValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.UnitMeasure.Cqrs; + +public class UpdateUnitMeasureValidator : AbstractValidator +{ + public UpdateUnitMeasureValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Unit Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/UnitMeasureEndpoint.cs b/Features/Inventory/UnitMeasure/UnitMeasureEndpoint.cs new file mode 100644 index 0000000..40233e3 --- /dev/null +++ b/Features/Inventory/UnitMeasure/UnitMeasureEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.UnitMeasure.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.UnitMeasure; + +public static class UnitMeasureEndpoint +{ + public static void MapUnitMeasureEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/unit-measure").WithTags("UnitMeasures") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetUnitMeasureListQuery()); + + return result.ToApiResponse("Unit measure list retrieved successfully"); + }) + .WithName("GetUnitMeasureList") + .WithTags("UnitMeasures"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetUnitMeasureByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Unit measure detail retrieved successfully" + : $"Unit measure with ID {id} not found"); + }) + .WithName("GetUnitMeasureById") + .WithTags("UnitMeasures"); + + group.MapPost("/", async (CreateUnitMeasureRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateUnitMeasureCommand(request)); + + return result.ToApiResponse("Unit measure has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateUnitMeasure") + .WithTags("UnitMeasures"); + + group.MapPost("/update", async (UpdateUnitMeasureRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateUnitMeasureCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The unit measure data could not be found."); + } + + return result.ToApiResponse("Unit measure has been updated successfully"); + }) + .WithName("UpdateUnitMeasure") + .WithTags("UnitMeasures"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteUnitMeasureByIdCommand(new DeleteUnitMeasureByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The unit measure data could not be found."); + } + + return true.ToApiResponse("Unit measure has been deleted successfully"); + }) + .WithName("DeleteUnitMeasureById") + .WithTags("UnitMeasures"); + } +} \ No newline at end of file diff --git a/Features/Inventory/UnitMeasure/UnitMeasureService.cs b/Features/Inventory/UnitMeasure/UnitMeasureService.cs new file mode 100644 index 0000000..de232fd --- /dev/null +++ b/Features/Inventory/UnitMeasure/UnitMeasureService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.UnitMeasure.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.UnitMeasure; + +public class UnitMeasureService : BaseService +{ + private readonly RestClient _client; + + public UnitMeasureService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetUnitMeasureListAsync() + { + var request = new RestRequest("api/unit-measure", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetUnitMeasureByIdAsync(string id) + { + var request = new RestRequest($"api/unit-measure/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateUnitMeasureAsync(CreateUnitMeasureRequest data) + { + var request = new RestRequest("api/unit-measure", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteUnitMeasureByIdAsync(string id) + { + var request = new RestRequest($"api/unit-measure/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateUnitMeasureAsync(UpdateUnitMeasureRequest data) + { + var request = new RestRequest("api/unit-measure/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Components/WarehousePage.razor b/Features/Inventory/Warehouse/Components/WarehousePage.razor new file mode 100644 index 0000000..e5bc564 --- /dev/null +++ b/Features/Inventory/Warehouse/Components/WarehousePage.razor @@ -0,0 +1,53 @@ +@page "/inventory/warehouse" +@using Indotalent.Features.Inventory.Warehouse +@using Indotalent.Features.Inventory.Warehouse.Cqrs +@using Indotalent.Features.Inventory.Warehouse.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_WarehouseCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_WarehouseUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_WarehouseDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateWarehouseRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateWarehouseRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Components/_WarehouseCreateForm.razor b/Features/Inventory/Warehouse/Components/_WarehouseCreateForm.razor new file mode 100644 index 0000000..a30dd92 --- /dev/null +++ b/Features/Inventory/Warehouse/Components/_WarehouseCreateForm.razor @@ -0,0 +1,104 @@ +@using Indotalent.Features.Inventory.Warehouse +@using Indotalent.Features.Inventory.Warehouse.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject WarehouseService WarehouseService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Warehouse + Create a new storage location for products. +
+
+
+ + + + + + Warehouse Name + + + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Warehouse + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateWarehouseValidator _validator = new(); + private CreateWarehouseRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await WarehouseService.CreateWarehouseAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Warehouse created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Components/_WarehouseDataTable.razor b/Features/Inventory/Warehouse/Components/_WarehouseDataTable.razor new file mode 100644 index 0000000..6057889 --- /dev/null +++ b/Features/Inventory/Warehouse/Components/_WarehouseDataTable.razor @@ -0,0 +1,382 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Warehouse +@using Indotalent.Features.Inventory.Warehouse.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject WarehouseService WarehouseService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Warehouse Management + Manage physical and virtual storage locations. +
+
+ + / + Inventory + / + Warehouse +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedWarehouse != null) + { + View + Edit + Remove + + } + else + { + + Add New Warehouse + + } +
+
+ + + + + + Warehouse Name + + System + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ + @if (context.SystemWarehouse == true) + { + SYSTEM + } + + @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _warehouses = new(); + private GetWarehouseListResponse? _selectedWarehouse; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedWarehouse = null; + StateHasChanged(); + try + { + var response = await WarehouseService.GetWarehouseListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _warehouses = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _warehouses; + return _warehouses.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedWarehouse = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Warehouses"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Warehouse Name"; + worksheet.Cell(currentRow, 2).Value = "System Warehouse"; + worksheet.Cell(currentRow, 3).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 3); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.SystemWarehouse == true ? "Yes" : "No"; + worksheet.Cell(currentRow, 3).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Warehouse_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedWarehouse = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedWarehouse = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedWarehouse == null) return; + var request = await MapToUpdateRequest(_selectedWarehouse.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedWarehouse == null) return; + var request = await MapToUpdateRequest(_selectedWarehouse.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await WarehouseService.GetWarehouseByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateWarehouseRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + SystemWarehouse = detail.SystemWarehouse, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedWarehouse == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedWarehouse.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await WarehouseService.DeleteWarehouseByIdAsync(_selectedWarehouse.Id); + if (isSuccess) + { + _selectedWarehouse = null; + await LoadData(); + Snackbar.Add("Warehouse deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. System warehouses cannot be removed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Components/_WarehouseUpdateForm.razor b/Features/Inventory/Warehouse/Components/_WarehouseUpdateForm.razor new file mode 100644 index 0000000..2c71545 --- /dev/null +++ b/Features/Inventory/Warehouse/Components/_WarehouseUpdateForm.razor @@ -0,0 +1,152 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Warehouse +@using Indotalent.Features.Inventory.Warehouse.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject WarehouseService WarehouseService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Warehouse Details" : "Edit Warehouse") + @(ReadOnly ? "Viewing warehouse location specification." : "Modify existing warehouse information.") +
+
+
+ + + + + + Warehouse Name + + + + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateWarehouseRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateWarehouseValidator _validator = new(); + private UpdateWarehouseRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateWarehouseRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + SystemWarehouse = Data.SystemWarehouse, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await WarehouseService.UpdateWarehouseAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Warehouse updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/CreateWarehouseHandler.cs b/Features/Inventory/Warehouse/Cqrs/CreateWarehouseHandler.cs new file mode 100644 index 0000000..5d47910 --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/CreateWarehouseHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class CreateWarehouseRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public bool? SystemWarehouse { get; set; } = false; +} + +public class CreateWarehouseResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateWarehouseCommand(CreateWarehouseRequest Data) : IRequest; + +public class CreateWarehouseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateWarehouseHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateWarehouseCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Warehouse + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Warehouse", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.Warehouse + { + Name = request.Data.Name, + Description = request.Data.Description, + SystemWarehouse = request.Data.SystemWarehouse + }; + + _context.Warehouse.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateWarehouseResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/CreateWarehouseValidator.cs b/Features/Inventory/Warehouse/Cqrs/CreateWarehouseValidator.cs new file mode 100644 index 0000000..42132c1 --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/CreateWarehouseValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class CreateWarehouseValidator : AbstractValidator +{ + public CreateWarehouseValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Warehouse Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/DeleteWarehouseByIdHandler.cs b/Features/Inventory/Warehouse/Cqrs/DeleteWarehouseByIdHandler.cs new file mode 100644 index 0000000..9af6494 --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/DeleteWarehouseByIdHandler.cs @@ -0,0 +1,32 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public record DeleteWarehouseByIdRequest(string Id); + +public record DeleteWarehouseByIdCommand(DeleteWarehouseByIdRequest Data) : IRequest; + +public class DeleteWarehouseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteWarehouseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteWarehouseByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Warehouse + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + if (entity.SystemWarehouse == true) + { + return false; + } + + _context.Warehouse.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/GetWarehouseByIdHandler.cs b/Features/Inventory/Warehouse/Cqrs/GetWarehouseByIdHandler.cs new file mode 100644 index 0000000..89b8b7f --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/GetWarehouseByIdHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class GetWarehouseByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool? SystemWarehouse { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetWarehouseByIdQuery(string Id) : IRequest; + +public class GetWarehouseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetWarehouseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetWarehouseByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Warehouse + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetWarehouseByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + SystemWarehouse = x.SystemWarehouse, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/GetWarehouseListHandler.cs b/Features/Inventory/Warehouse/Cqrs/GetWarehouseListHandler.cs new file mode 100644 index 0000000..d06fcb1 --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/GetWarehouseListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class GetWarehouseListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool? SystemWarehouse { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetWarehouseListQuery() : IRequest>; + +public class GetWarehouseListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetWarehouseListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetWarehouseListQuery request, CancellationToken cancellationToken) + { + return await _context.Warehouse + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetWarehouseListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + SystemWarehouse = x.SystemWarehouse, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseHandler.cs b/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseHandler.cs new file mode 100644 index 0000000..d918f47 --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class UpdateWarehouseRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool? SystemWarehouse { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateWarehouseResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateWarehouseCommand(UpdateWarehouseRequest Data) : IRequest; + +public class UpdateWarehouseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateWarehouseHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateWarehouseCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Warehouse + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Warehouse", request.Data.Name ?? string.Empty); + } + + var entity = await _context.Warehouse + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateWarehouseResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.SystemWarehouse = request.Data.SystemWarehouse; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateWarehouseResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseValidator.cs b/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseValidator.cs new file mode 100644 index 0000000..042b27b --- /dev/null +++ b/Features/Inventory/Warehouse/Cqrs/UpdateWarehouseValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Inventory.Warehouse.Cqrs; + +public class UpdateWarehouseValidator : AbstractValidator +{ + public UpdateWarehouseValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Warehouse Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/WarehouseEndpoint.cs b/Features/Inventory/Warehouse/WarehouseEndpoint.cs new file mode 100644 index 0000000..a447713 --- /dev/null +++ b/Features/Inventory/Warehouse/WarehouseEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.Warehouse.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.Warehouse; + +public static class WarehouseEndpoint +{ + public static void MapWarehouseEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/warehouse").WithTags("Warehouses") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetWarehouseListQuery()); + + return result.ToApiResponse("Warehouse list retrieved successfully"); + }) + .WithName("GetWarehouseList") + .WithTags("Warehouses"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetWarehouseByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Warehouse detail retrieved successfully" + : $"Warehouse with ID {id} not found"); + }) + .WithName("GetWarehouseById") + .WithTags("Warehouses"); + + group.MapPost("/", async (CreateWarehouseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateWarehouseCommand(request)); + + return result.ToApiResponse("Warehouse has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateWarehouse") + .WithTags("Warehouses"); + + group.MapPost("/update", async (UpdateWarehouseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateWarehouseCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The warehouse data could not be found."); + } + + return result.ToApiResponse("Warehouse has been updated successfully"); + }) + .WithName("UpdateWarehouse") + .WithTags("Warehouses"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteWarehouseByIdCommand(new DeleteWarehouseByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The warehouse might be a system warehouse or not found."); + } + + return true.ToApiResponse("Warehouse has been deleted successfully"); + }) + .WithName("DeleteWarehouseById") + .WithTags("Warehouses"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Warehouse/WarehouseService.cs b/Features/Inventory/Warehouse/WarehouseService.cs new file mode 100644 index 0000000..fdc7499 --- /dev/null +++ b/Features/Inventory/Warehouse/WarehouseService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.Warehouse.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.Warehouse; + +public class WarehouseService : BaseService +{ + private readonly RestClient _client; + + public WarehouseService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetWarehouseListAsync() + { + var request = new RestRequest("api/warehouse", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetWarehouseByIdAsync(string id) + { + var request = new RestRequest($"api/warehouse/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateWarehouseAsync(CreateWarehouseRequest data) + { + var request = new RestRequest("api/warehouse", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteWarehouseByIdAsync(string id) + { + var request = new RestRequest($"api/warehouse/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateWarehouseAsync(UpdateWarehouseRequest data) + { + var request = new RestRequest("api/warehouse/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/AvatarEndpoint.cs b/Features/Profile/Avatar/AvatarEndpoint.cs new file mode 100644 index 0000000..7a28a56 --- /dev/null +++ b/Features/Profile/Avatar/AvatarEndpoint.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.Avatar.Cqrs; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Profile.Avatar; + +public static class AvatarEndpoint +{ + public static void MapAvatarEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/profile-avatar").WithTags("Profile Avatar") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/info/{userId}", async (string userId, IMediator mediator) => + { + var result = await mediator.Send(new GetAvatarInfoQuery(userId)); + return result.ToApiResponse("Avatar info retrieved successfully"); + }) + .WithName("GetMyAvatarInfo"); + + group.MapPost("/change", async (ChangeAvatarRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeAvatarCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ProfileChangeAvatar"); + + group.MapGet("/avatar-image/{fileName}", async (string fileName, IOptions options, IWebHostEnvironment env) => + { + var avatarSettings = options.Value.Avatar; + var folderPath = Path.Combine(env.WebRootPath, avatarSettings.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (!System.IO.File.Exists(filePath)) return Results.NotFound(); + + var bytes = await System.IO.File.ReadAllBytesAsync(filePath); + var extension = Path.GetExtension(fileName).ToLower(); + var contentType = extension switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + _ => "application/octet-stream" + }; + return Results.File(bytes, contentType); + }) + .WithName("ProfileGetAvatarImage"); + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/AvatarService.cs b/Features/Profile/Avatar/AvatarService.cs new file mode 100644 index 0000000..fa6f2a3 --- /dev/null +++ b/Features/Profile/Avatar/AvatarService.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.Avatar.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.Avatar; + +public class AvatarService : BaseService +{ + private readonly RestClient _client; + + public AvatarService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetAvatarInfoAsync(string userId) + { + var request = new RestRequest($"api/profile-avatar/info/{userId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> ChangeAvatarAsync(ChangeAvatarRequest data) + { + var request = new RestRequest("api/profile-avatar/change", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task GetAvatarBlobAsync(string fileName) + { + var request = new RestRequest($"api/profile-avatar/avatar-image/{fileName}", Method.Get); + + if (!string.IsNullOrEmpty(TokenProvider.Token)) + { + request.AddHeader("Authorization", $"Bearer {TokenProvider.Token}"); + } + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddHeader("X-UserId", CurrentUserService.UserId); + } + + var response = await _client.ExecuteAsync(request); + + return response.IsSuccessful ? response.RawBytes : null; + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Components/AvatarPage.razor b/Features/Profile/Avatar/Components/AvatarPage.razor new file mode 100644 index 0000000..81224dc --- /dev/null +++ b/Features/Profile/Avatar/Components/AvatarPage.razor @@ -0,0 +1,198 @@ +@page "/profile/avatar" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using System.IO +@using Indotalent.Features.Profile.Avatar +@using Indotalent.Features.Profile.Avatar.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@inject ISnackbar Snackbar +@inject AvatarService AvatarService +@inject ICurrentUserService CurrentUserService + + +
+ Profile Picture + Update your avatar and how you appear to other members. +
+ +
+ + / + Profile + / + Avatar +
+
+ + + + + + Your Avatar + Recommended size: 200x200px. Supported formats: JPG, JPEG, PNG. (max 2MB). + + + +
+ + + @if (_isLoading) + { + + } + else if (!string.IsNullOrEmpty(_previewUrl)) + { +
+ +
+ } + else + { + + @(CurrentUserService.FullName?.Substring(0, 1).ToUpper() ?? "U") + + } + +
+ Avatar Preview + This is how your photo will look. +
+ + + + @(_selectedFile == null ? "Browse File" : _selectedFile.Name) + + +
+
+
+
+ +
+ + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + +
+
+
+ +@code { + private IBrowserFile? _selectedFile; + private string? _previewUrl; + private bool _processing = false; + private bool _isLoading = false; + private string? _fullName; + + protected override async Task OnInitializedAsync() + { + await LoadExistingAvatar(); + } + + private async Task LoadExistingAvatar() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _isLoading = true; + try + { + var response = await AvatarService.GetAvatarInfoAsync(userId); + + if (response != null && response.IsSuccess && response.Value != null) + { + _fullName = response.Value.FullName; + + if (!string.IsNullOrEmpty(response.Value.AvatarFile)) + { + var bytes = await AvatarService.GetAvatarBlobAsync(response.Value.AvatarFile); + if (bytes != null && bytes.Length > 0) + { + _previewUrl = $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + } + } + } + catch (Exception) { } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private async Task HandleFileSelected(IBrowserFile file) + { + if (file == null) return; + if (file.Size > 2 * 1024 * 1024) + { + Snackbar.Add("File too large. Max 2MB allowed.", Severity.Warning); + return; + } + _selectedFile = file; + using var stream = file.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + _previewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}"; + StateHasChanged(); + } + + private async Task SubmitAvatar() + { + if (_selectedFile == null) return; + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _processing = true; + try + { + using var stream = _selectedFile.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + var request = new ChangeAvatarRequest + { + UserId = userId, + FileData = ms.ToArray(), + FileName = _selectedFile.Name + }; + + var response = await AvatarService.ChangeAvatarAsync(request); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Avatar updated successfully", Severity.Success); + _selectedFile = null; + await LoadExistingAvatar(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Upload failed: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs b/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs new file mode 100644 index 0000000..13b96fe --- /dev/null +++ b/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs @@ -0,0 +1,66 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Avatar.Cqrs; + +public class ChangeAvatarRequest +{ + public string UserId { get; set; } = string.Empty; + public byte[] FileData { get; set; } = Array.Empty(); + public string FileName { get; set; } = string.Empty; +} + +public class ChangeAvatarResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record ChangeAvatarCommand(ChangeAvatarRequest Data) : IRequest; + +public class ChangeAvatarHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly FileStorageService _fileStorage; + + public ChangeAvatarHandler(UserManager userManager, FileStorageService fileStorage) + { + _userManager = userManager; + _fileStorage = fileStorage; + } + + public async Task Handle(ChangeAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeAvatarResponse { IsSuccess = false, Message = "User not found" }; + + try + { + var extension = Path.GetExtension(request.Data.FileName); + + if (!string.IsNullOrEmpty(user.AvatarFile)) + { + _fileStorage.DeleteOldAvatar(user.AvatarFile); + } + + var newFileName = await _fileStorage.SaveAvatarAsync(user.Id, request.Data.FileData, extension); + + user.AvatarFile = newFileName; + user.UpdatedAt = DateTime.Now; + + var result = await _userManager.UpdateAsync(user); + + return new ChangeAvatarResponse + { + IsSuccess = result.Succeeded, + Message = result.Succeeded ? "Avatar updated successfully" : "Failed to update user profile" + }; + } + catch (Exception ex) + { + return new ChangeAvatarResponse { IsSuccess = false, Message = ex.Message }; + } + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs b/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs new file mode 100644 index 0000000..20fc4fa --- /dev/null +++ b/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs @@ -0,0 +1,37 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Avatar.Cqrs; + +public class GetAvatarInfoResponse +{ + public string Id { get; set; } = string.Empty; + public string? FullName { get; set; } + public string? AvatarFile { get; set; } +} + +public record GetAvatarInfoQuery(string UserId) : IRequest; + +public class GetAvatarInfoHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public GetAvatarInfoHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(GetAvatarInfoQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) return null; + + return new GetAvatarInfoResponse + { + Id = user.Id, + FullName = user.FullName, + AvatarFile = user.AvatarFile + }; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Components/PasswordPage.razor b/Features/Profile/Password/Components/PasswordPage.razor new file mode 100644 index 0000000..f0c14bd --- /dev/null +++ b/Features/Profile/Password/Components/PasswordPage.razor @@ -0,0 +1,195 @@ +@page "/profile/password" +@using System.ComponentModel.DataAnnotations +@using MudBlazor +@using Indotalent.Features.Profile.Password +@using Indotalent.Features.Profile.Password.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Shared.Utils +@inject ISnackbar Snackbar +@inject PasswordService PasswordService +@inject ICurrentUserService CurrentUserService + + +
+ Security Settings + Manage your password and protect your account security. +
+ +
+ + / + Profile + / + Password +
+
+ + + + + + + Change Password + Update your password to keep your account secure. We recommend using a strong password. + + + +
+ Current Password + +
+ + + +
+ New Password + +
+ +
+ Confirm New Password + +
+
+
+
+ +
+ + @if (_isProcessing) + { + + Updating Password... + } + else + { + Update Password + } + +
+
+
+
+ +@code { + private MudForm _form = default!; + private UpdatePasswordValidator _validator = new(); + private bool _isProcessing = false; + private PasswordChangeModel _model = new(); + + private bool _currentPasswordVisibility; + private InputType _currentPasswordInput = InputType.Password; + private string _currentPasswordIcon = Icons.Material.Filled.VisibilityOff; + + private bool _newPasswordVisibility; + private InputType _newPasswordInput = InputType.Password; + private string _newPasswordIcon = Icons.Material.Filled.VisibilityOff; + + private void ToggleCurrentPasswordVisibility() + { + if (_currentPasswordVisibility) + { + _currentPasswordVisibility = false; + _currentPasswordIcon = Icons.Material.Filled.VisibilityOff; + _currentPasswordInput = InputType.Password; + } + else + { + _currentPasswordVisibility = true; + _currentPasswordIcon = Icons.Material.Filled.Visibility; + _currentPasswordInput = InputType.Text; + } + } + + private void ToggleNewPasswordVisibility() + { + if (_newPasswordVisibility) + { + _newPasswordVisibility = false; + _newPasswordIcon = Icons.Material.Filled.VisibilityOff; + _newPasswordInput = InputType.Password; + } + else + { + _newPasswordVisibility = true; + _newPasswordIcon = Icons.Material.Filled.Visibility; + _newPasswordInput = InputType.Text; + } + } + + private async Task UpdatePassword() + { + await _form.Validate(); + + if (!_form.IsValid) + { + Snackbar.Add("Please correct the validation errors.", Severity.Warning); + return; + } + + if (_model.NewPassword != _model.ConfirmPassword) + { + Snackbar.Add("New password and confirmation do not match.", Severity.Error); + return; + } + + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) + { + Snackbar.Add("User session not found.", Severity.Error); + return; + } + + _isProcessing = true; + + var request = new UpdatePasswordRequest + { + Id = userId, + CurrentPassword = _model.CurrentPassword, + NewPassword = _model.NewPassword + }; + + var response = await PasswordService.UpdatePasswordAsync(request); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Password updated successfully!", Severity.Success); + _model = new PasswordChangeModel(); + await _form.ResetAsync(); + } + else + { + Snackbar.Add(response?.Message ?? "Failed to update password", Severity.Error); + } + + _isProcessing = false; + } + + public class PasswordChangeModel : UpdatePasswordRequest + { + public string ConfirmPassword { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs b/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs new file mode 100644 index 0000000..5b49644 --- /dev/null +++ b/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Password.Cqrs; + +public class UpdatePasswordRequest +{ + public string Id { get; set; } = string.Empty; + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class UpdatePasswordResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List? Errors { get; set; } +} + +public record UpdatePasswordCommand(UpdatePasswordRequest Data) : IRequest; + +public class UpdatePasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public UpdatePasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(UpdatePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id); + if (user == null) + return new UpdatePasswordResponse { Success = false, Message = "User not found" }; + + var result = await _userManager.ChangePasswordAsync(user, request.Data.CurrentPassword, request.Data.NewPassword); + + if (result.Succeeded) + return new UpdatePasswordResponse { Success = true }; + + return new UpdatePasswordResponse + { + Success = false, + Message = "Failed to update password", + Errors = result.Errors.Select(x => x.Description).ToList() + }; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs b/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs new file mode 100644 index 0000000..d1bf8fd --- /dev/null +++ b/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Indotalent.Features.Profile.Password.Cqrs; + +public class UpdatePasswordValidator : AbstractValidator +{ + public UpdatePasswordValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Current password is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(4).WithMessage("Password must be at least 4 characters"); + } +} \ No newline at end of file diff --git a/Features/Profile/Password/PasswordEndpoint.cs b/Features/Profile/Password/PasswordEndpoint.cs new file mode 100644 index 0000000..6b8e6ef --- /dev/null +++ b/Features/Profile/Password/PasswordEndpoint.cs @@ -0,0 +1,26 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.Password.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Profile.Password; + +public static class PasswordEndpoint +{ + public static void MapPasswordEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/profile-password").WithTags("Profile Password") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapPost("/update", async (UpdatePasswordRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePasswordCommand(request)); + if (!result.Success) + return ((object?)null).ToApiResponse(result.Message ?? "Failed to update password", StatusCodes.Status400BadRequest); + + return result.ToApiResponse("Password has been updated successfully"); + }) + .WithName("UpdateProfilePassword"); + } +} \ No newline at end of file diff --git a/Features/Profile/Password/PasswordService.cs b/Features/Profile/Password/PasswordService.cs new file mode 100644 index 0000000..6d6f720 --- /dev/null +++ b/Features/Profile/Password/PasswordService.cs @@ -0,0 +1,28 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.Password.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.Password; + +public class PasswordService : BaseService +{ + private readonly RestClient _client; + + public PasswordService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> UpdatePasswordAsync(UpdatePasswordRequest data) + { + var request = new RestRequest("api/profile-password/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor b/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor new file mode 100644 index 0000000..1ec8305 --- /dev/null +++ b/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor @@ -0,0 +1,275 @@ +@page "/profile/personal-information" +@using System.ComponentModel.DataAnnotations +@using MudBlazor +@using Indotalent.Features.Profile.PersonalInformation +@using Indotalent.Features.Profile.PersonalInformation.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@inject ISnackbar Snackbar +@inject PersonalInformationService PersonalInformationService +@inject ICurrentUserService CurrentUserService + + +
+ Personal Information + Manage your personal details, contact information, and how others see you. +
+ +
+ + / + Profile + / + Personal Information +
+
+ + + + + + Basic Profile + Your public identity and bio. + + + + + + First Name + + + + Last Name + + + + +
+ Short Bio + +
+ + + + Job Title / Position + + + + Date of Birth + + + +
+
+
+ + + + + + Contact & Location + How we can reach you and where you're based. + + + + + + Email Address + + + + Phone Number + + + + +
+ Residential Address + +
+ + + + City + + + + Country + + + + Postal Code + + + +
+
+
+ + + + + + Social Profiles + Your professional and social networking links. + + + +
+ LinkedIn Profile + +
+ +
+ GitHub / Portfolio + +
+ +
+ Instagram + +
+
+
+
+ +
+ + @if (_isProcessing) + { + + Saving... + } + else + { + Save Changes + } + +
+
+
+ +@code { + private bool _isProcessing = false; + private UserProfileModel user = new(); + + protected override async Task OnInitializedAsync() + { + await LoadProfile(); + } + + private async Task LoadProfile() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _isProcessing = true; + + var response = await PersonalInformationService.GetPersonalInformationByIdAsync(userId); + + if (response != null && response.IsSuccess && response.Value != null) + { + var profile = response.Value; + + user = new UserProfileModel + { + FirstName = profile.FirstName ?? string.Empty, + LastName = profile.LastName ?? string.Empty, + Bio = profile.ShortBio ?? string.Empty, + JobTitle = profile.JobTitle ?? string.Empty, + DateOfBirth = profile.DateOfBirth, + Contact = new ContactModel + { + Email = profile.Email ?? string.Empty, + Phone = profile.PhoneNumber ?? string.Empty, + Address = profile.StreetAddress ?? string.Empty, + City = profile.City ?? string.Empty, + Country = profile.Country ?? string.Empty, + PostalCode = profile.ZipCode ?? string.Empty + }, + Socials = new UserSocialModel + { + LinkedIn = profile.SocialMediaLinkedIn ?? string.Empty, + Instagram = profile.SocialMediaInstagram ?? string.Empty, + GitHub = profile.SocialMediaX ?? string.Empty + } + }; + } + _isProcessing = false; + } + + private async Task SaveProfile() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) + { + Snackbar.Add("User session not found.", Severity.Error); + return; + } + + _isProcessing = true; + + var updateRequest = new UpdateProfileRequest + { + Id = userId, + FirstName = user.FirstName, + LastName = user.LastName, + ShortBio = user.Bio, + JobTitle = user.JobTitle, + DateOfBirth = user.DateOfBirth, + PhoneNumber = user.Contact.Phone, + StreetAddress = user.Contact.Address, + City = user.Contact.City, + Country = user.Contact.Country, + ZipCode = user.Contact.PostalCode, + SocialMediaLinkedIn = user.Socials.LinkedIn, + SocialMediaInstagram = user.Socials.Instagram, + SocialMediaX = user.Socials.GitHub + }; + + var result = await PersonalInformationService.UpdatePersonalInformationAsync(updateRequest); + await Task.Delay(500); + + if (result != null && result.IsSuccess) + { + Snackbar.Add("Profile updated! Please re-login to see all changes in the session.", Severity.Success, config => + { + config.VisibleStateDuration = 5000; + }); + + } + else + { + Snackbar.Add(result?.Message ?? "Update failed", Severity.Error); + } + + _isProcessing = false; + } + + public class UserProfileModel + { + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public string JobTitle { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public ContactModel Contact { get; set; } = new(); + public UserSocialModel Socials { get; set; } = new(); + } + + public class ContactModel + { + public string Email { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Address { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public string PostalCode { get; set; } = string.Empty; + } + + public class UserSocialModel + { + public string LinkedIn { get; set; } = string.Empty; + public string GitHub { get; set; } = string.Empty; + public string Instagram { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs b/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs new file mode 100644 index 0000000..8d981d7 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class GetProfileByIdResponse +{ + public string Id { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime? DateOfBirth { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? ZipCode { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaX { get; set; } +} + +public record GetProfileByIdQuery(string Id) : IRequest; + +public class GetProfileByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProfileByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProfileByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProfileByIdResponse + { + Id = x.Id, + FirstName = x.FirstName, + LastName = x.LastName, + ShortBio = x.ShortBio, + JobTitle = x.JobTitle, + DateOfBirth = x.DateOfBirth, + Email = x.Email, + PhoneNumber = x.PhoneNumber, + StreetAddress = x.StreetAddress, + City = x.City, + Country = x.Country, + ZipCode = x.ZipCode, + SocialMediaLinkedIn = x.SocialMediaLinkedIn, + SocialMediaInstagram = x.SocialMediaInstagram, + SocialMediaX = x.SocialMediaX + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs new file mode 100644 index 0000000..0229793 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class UpdateProfileRequest +{ + public string Id { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime? DateOfBirth { get; set; } + public string? PhoneNumber { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? ZipCode { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaX { get; set; } +} + +public class UpdateProfileResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record UpdateProfileCommand(UpdateProfileRequest Data) : IRequest; + +public class UpdateProfileHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProfileHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProfileCommand request, CancellationToken cancellationToken) + { + var user = await _context.Users + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (user == null) + return new UpdateProfileResponse { Success = false, Message = "User not found" }; + + user.FullName = $"{request.Data.FirstName} {request.Data.LastName}"; + user.FirstName = request.Data.FirstName; + user.LastName = request.Data.LastName; + user.ShortBio = request.Data.ShortBio; + user.JobTitle = request.Data.JobTitle; + user.DateOfBirth = request.Data.DateOfBirth ?? user.DateOfBirth; + user.PhoneNumber = request.Data.PhoneNumber; + user.StreetAddress = request.Data.StreetAddress; + user.City = request.Data.City; + user.Country = request.Data.Country; + user.ZipCode = request.Data.ZipCode; + user.SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty; + user.SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty; + user.SocialMediaX = request.Data.SocialMediaX ?? string.Empty; + user.UpdatedAt = DateTime.Now; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProfileResponse { Success = true }; + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs new file mode 100644 index 0000000..3a5d065 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class UpdateProfileValidator : AbstractValidator +{ + public UpdateProfileValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"First name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Last name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Phone cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.ShortBio) + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Bio cannot exceed {GlobalConsts.StringLengthShort} characters"); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs b/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs new file mode 100644 index 0000000..9940a19 --- /dev/null +++ b/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs @@ -0,0 +1,35 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.PersonalInformation.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Profile.PersonalInformation; + +public static class PersonalInformationEndpoint +{ + public static void MapPersonalInformationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/personal-information").WithTags("Personal Information") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProfileByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Personal information retrieved successfully" + : $"User with ID {id} not found"); + }) + .WithName("GetPersonalInformationById"); + + group.MapPost("/update", async (UpdateProfileRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProfileCommand(request)); + if (!result.Success) + return ((object?)null).ToApiResponse(result.Message ?? "Update failed.", StatusCodes.Status400BadRequest); + + return result.ToApiResponse("Personal information has been updated successfully"); + }) + .WithName("UpdatePersonalInformation"); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/PersonalInformationService.cs b/Features/Profile/PersonalInformation/PersonalInformationService.cs new file mode 100644 index 0000000..06867f2 --- /dev/null +++ b/Features/Profile/PersonalInformation/PersonalInformationService.cs @@ -0,0 +1,34 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.PersonalInformation.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.PersonalInformation; + +public class PersonalInformationService : BaseService +{ + private readonly RestClient _client; + + public PersonalInformationService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetPersonalInformationByIdAsync(string id) + { + var request = new RestRequest($"api/personal-information/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePersonalInformationAsync(UpdateProfileRequest data) + { + var request = new RestRequest("api/personal-information/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/ProfilePage.razor b/Features/Profile/ProfilePage.razor new file mode 100644 index 0000000..fec5af0 --- /dev/null +++ b/Features/Profile/ProfilePage.razor @@ -0,0 +1,103 @@ +@page "/profile" +@using Indotalent.Features.Profile.Session +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Profile.Avatar.Components +@using Indotalent.Features.Profile.Password.Components +@using Indotalent.Features.Profile.PersonalInformation +@using Indotalent.Features.Profile.Password +@using Indotalent.Features.Profile.Avatar +@using Indotalent.Features.Profile.PersonalInformation.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "personal" => 0, + "password" => 1, + "avatar" => 2, + "session" => 3, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "personal", + 1 => "password", + 2 => "avatar", + 3 => "session", + _ => "personal" + }; + + NavigationManager.NavigateTo($"/profile?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Profile/Session/SessionPage.razor b/Features/Profile/Session/SessionPage.razor new file mode 100644 index 0000000..bea64b1 --- /dev/null +++ b/Features/Profile/Session/SessionPage.razor @@ -0,0 +1,105 @@ +@page "/profile/session" +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.ConfigFrontEnd.Common +@using Indotalent.Infrastructure.Authentication +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.JSInterop +@using MudBlazor +@inject ICurrentUserService CurrentUserService +@inject TokenProvider TokenProvider +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + + Active Session Information + Detailed information about your current authentication state. + +
+
+ Full Name + @(CurrentUserService.FullName ?? "Not Identified") +
+ +
+ User ID + @(CurrentUserService.UserId ?? "Not Identified") +
+ +
+ Username + @(CurrentUserService.UserName ?? "Not Identified") +
+ +
+ Email Address + @(CurrentUserService.Email ?? "Not Identified") +
+
+ + + +
+
+ JSON Web Token (JWT) + Copy this token to use in Swagger Authorization. +
+ + Copy JWT Token + +
+
+ +@code { + [Inject] private IHttpContextAccessor HttpContextAccessor { get; set; } = default!; + + protected override void OnInitialized() + { + if (string.IsNullOrEmpty(TokenProvider.Token)) + { + var tokenFromCookie = HttpContextAccessor.HttpContext?.Request.Cookies["X-Auth-Token"]; + if (!string.IsNullOrEmpty(tokenFromCookie)) + { + TokenProvider.Token = tokenFromCookie; + } + } + } + + private async Task CopyToClipboard() + { + var tokenValue = TokenProvider.Token; + + if (string.IsNullOrEmpty(tokenValue)) + { + Snackbar.Add("Token not found. Please re-login.", Severity.Error); + return; + } + + await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", tokenValue); + Snackbar.Add("Token copied to clipboard!", Severity.Success); + } + + private async Task ShowJwtDialog() + { + var tokenValue = TokenProvider.Token; + + if (string.IsNullOrEmpty(tokenValue)) + { + await DialogService.ShowMessageBoxAsync("System Note", "Token is not available."); + return; + } + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + + await DialogService.ShowMessageBoxAsync( + "Your JWT Token", + (MarkupString)$@"

RAW TOKEN:

{tokenValue}
", + "Close", + options: options + ); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/BillEndpoint.cs b/Features/Purchase/Bill/BillEndpoint.cs new file mode 100644 index 0000000..351133f --- /dev/null +++ b/Features/Purchase/Bill/BillEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.Bill.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.Bill; + +public static class BillEndpoint +{ + public static void MapBillEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/bill").WithTags("Bills") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBillListQuery()); + return result.ToApiResponse("Bill list retrieved successfully"); + }) + .WithName("GetBillList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBillByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Bill detail retrieved successfully" + : $"Bill with ID {id} not found"); + }) + .WithName("GetBillById"); + + group.MapGet("/purchase-order-detail/{poId}", async (string poId, IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseOrderDetailForBillQuery(poId)); + return result.ToApiResponse(result is not null + ? "Purchase order detail for bill retrieved successfully" + : $"Purchase order with ID {poId} not found"); + }) + .WithName("GetPurchaseOrderDetailForBill"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBillLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetBillLookup"); + + group.MapPost("/", async (CreateBillRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBillCommand(request)); + return result.ToApiResponse("Bill has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBill"); + + group.MapPost("/update", async (UpdateBillRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBillCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Bill not found."); + } + return result.ToApiResponse("Bill has been updated successfully"); + }) + .WithName("UpdateBill"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBillByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Bill not found."); + } + return true.ToApiResponse("Bill has been deleted successfully"); + }) + .WithName("DeleteBillById"); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/BillPdfGenerator.cs b/Features/Purchase/Bill/BillPdfGenerator.cs new file mode 100644 index 0000000..dc55173 --- /dev/null +++ b/Features/Purchase/Bill/BillPdfGenerator.cs @@ -0,0 +1,129 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.Bill.Cqrs; + +namespace Indotalent.Features.Purchase.Bill; + +public class BillPdfGenerator +{ + public static byte[] Generate(GetBillByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("VENDOR BILL") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + col.Item().Text($"PO Ref: {data.PurchaseOrderAutoNumber}").FontSize(9).FontColor(textSlate); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("VENDOR").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.VendorName).Bold(); + c.Item().Text(data.VendorStreet); + c.Item().Text(data.VendorCity); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("BILL DETAILS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Bill Date"); + r.RelativeItem().AlignRight().Text(data.BillDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Status"); + r.RelativeItem().AlignRight().Text(data.BillStatus.ToString().ToUpper()).Bold().FontColor(primaryBlue); + }); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(4); + columns.RelativeColumn(2); + columns.RelativeColumn(1.5f); + columns.RelativeColumn(2.5f); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Unit Price"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Total ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)).PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.UnitPrice?.ToString("N2")); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Quantity?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total?.ToString("N2")); + + static IContainer CellStyle(IContainer container) => container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Notes:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { r.RelativeItem().Text("Sub Total"); r.RelativeItem().AlignRight().Text(data.BeforeTaxAmount?.ToString("N2")); }); + c.Item().Row(r => { r.RelativeItem().Text("Tax Amount"); r.RelativeItem().AlignRight().Text(data.TaxAmount?.ToString("N2")); }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { r.RelativeItem().Text($"TOTAL").Bold(); r.RelativeItem().AlignRight().Text(data.AfterTaxAmount?.ToString("N2")).ExtraBold().FontColor(primaryBlue); }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => { x.Span("Page ").FontSize(8); x.CurrentPageNumber().FontSize(8); }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/BillService.cs b/Features/Purchase/Bill/BillService.cs new file mode 100644 index 0000000..54a1975 --- /dev/null +++ b/Features/Purchase/Bill/BillService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.Bill.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.Bill; + +public class BillService : BaseService +{ + private readonly RestClient _client; + + public BillService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBillListAsync() + { + var request = new RestRequest("api/bill", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBillByIdAsync(string id) + { + var request = new RestRequest($"api/bill/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPurchaseOrderDetailForBillAsync(string poId) + { + var request = new RestRequest($"api/bill/purchase-order-detail/{poId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetBillLookupAsync() + { + var request = new RestRequest("api/bill/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBillAsync(CreateBillRequest data) + { + var request = new RestRequest("api/bill", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateBillAsync(UpdateBillRequest data) + { + var request = new RestRequest("api/bill/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBillByIdAsync(string id) + { + var request = new RestRequest($"api/bill/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Components/BillPage.razor b/Features/Purchase/Bill/Components/BillPage.razor new file mode 100644 index 0000000..efe3388 --- /dev/null +++ b/Features/Purchase/Bill/Components/BillPage.razor @@ -0,0 +1,51 @@ +@page "/purchase/bill" +@using Indotalent.Features.Purchase.Bill.Cqrs +@using Indotalent.Features.Purchase.Bill.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BillCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BillUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_BillDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateBillRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBillRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Components/_BillCreateForm.razor b/Features/Purchase/Bill/Components/_BillCreateForm.razor new file mode 100644 index 0000000..8ffb09e --- /dev/null +++ b/Features/Purchase/Bill/Components/_BillCreateForm.razor @@ -0,0 +1,181 @@ +@using Indotalent.Features.Purchase.Bill.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BillService BillService +@inject ISnackbar Snackbar + + +
+ +
+ Add Bill + Create a new vendor bill from Purchase Order. +
+
+
+ + + + + Basic Information + + + Bill Date + + + + + Purchase Order Reference + + @foreach (var item in _lookup.PurchaseOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_BillPurchaseOrderItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Payment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Amount + @_afterTax.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Bill + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBillRequest _model = new() { BillDate = DateTime.Today, BillStatus = Indotalent.Data.Enums.BillStatus.Draft }; + private BillLookupResponse _lookup = new(); + private List _items = new(); + private decimal _beforeTax = 0; + private decimal _taxAmount = 0; + private decimal _afterTax = 0; + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await BillService.GetBillLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnPurchaseOrderChanged(string poId) + { + _model.PurchaseOrderId = poId; + if (string.IsNullOrEmpty(poId)) + { + _items.Clear(); + _beforeTax = 0; + _taxAmount = 0; + _afterTax = 0; + return; + } + + var res = await BillService.GetPurchaseOrderDetailForBillAsync(poId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _beforeTax = res.Value.BeforeTaxAmount ?? 0; + _taxAmount = res.Value.TaxAmount ?? 0; + _afterTax = res.Value.AfterTaxAmount ?? 0; + Snackbar.Add("Purchase Order reference selected. Payment summary and items have been loaded.", Severity.Info); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BillService.CreateBillAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Components/_BillDataTable.razor b/Features/Purchase/Bill/Components/_BillDataTable.razor new file mode 100644 index 0000000..954468a --- /dev/null +++ b/Features/Purchase/Bill/Components/_BillDataTable.razor @@ -0,0 +1,380 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.Bill +@using Indotalent.Features.Purchase.Bill.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BillService BillService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Vendor Bill + Manage bills received from vendors based on Purchase Orders. +
+
+ + / + Purchase + / + Bill +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Bill + + } +
+
+ + + + + + Bill No + + + Date + + + PO Ref + + + Vendor + + + Status + + + Total + + + + + + + @context.AutoNumber + @context.BillDate?.ToString("yyyy-MM-dd") + @context.PurchaseOrderAutoNumber + @context.VendorName + + @context.BillStatus.ToString().ToUpper() + + @context.AfterTaxAmount?.ToString("N2") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetBillListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await BillService.GetBillListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PurchaseOrderAutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Bills"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Bill No"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "PO Ref"; + worksheet.Cell(currentRow, 4).Value = "Vendor"; + worksheet.Cell(currentRow, 5).Value = "Status"; + worksheet.Cell(currentRow, 6).Value = "Total Amount"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.BillDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.PurchaseOrderAutoNumber; + worksheet.Cell(currentRow, 4).Value = item.VendorName; + worksheet.Cell(currentRow, 5).Value = item.BillStatus.ToString(); + worksheet.Cell(currentRow, 6).Value = item.AfterTaxAmount; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Bill_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await BillService.GetBillByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateBillRequest + { + Id = detail.Id, + BillDate = detail.BillDate, + BillStatus = detail.BillStatus, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + PurchaseOrderId = detail.PurchaseOrderId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await BillService.DeleteBillByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Components/_BillPurchaseOrderItemDataTable.razor b/Features/Purchase/Bill/Components/_BillPurchaseOrderItemDataTable.razor new file mode 100644 index 0000000..ea10eb5 --- /dev/null +++ b/Features/Purchase/Bill/Components/_BillPurchaseOrderItemDataTable.razor @@ -0,0 +1,35 @@ +@using Indotalent.Features.Purchase.Bill.Cqrs +@using MudBlazor + + +
+ Referenced Purchase Order Items +
+ + + + Product + Summary + Unit Price + Quantity + Total + + + @context.ProductName + @context.Summary + @context.UnitPrice?.ToString("N2") + @context.Quantity + @context.Total?.ToString("N2") + + +
+ + + + +@code { + [Parameter] public List Items { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = true; +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Components/_BillUpdateForm.razor b/Features/Purchase/Bill/Components/_BillUpdateForm.razor new file mode 100644 index 0000000..f978ac0 --- /dev/null +++ b/Features/Purchase/Bill/Components/_BillUpdateForm.razor @@ -0,0 +1,265 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.Bill.Cqrs +@using Indotalent.Features.Purchase.Bill.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject BillService BillService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Bill + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Bill Date + + + + + Purchase Order Reference + + @foreach (var item in _lookup.PurchaseOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_BillPurchaseOrderItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Payment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Amount + @_afterTax.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateBillRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateBillRequest _model = new(); + private List _items = new(); + private BillLookupResponse _lookup = new(); + private decimal _beforeTax = 0; + private decimal _taxAmount = 0; + private decimal _afterTax = 0; + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await BillService.GetBillLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnPurchaseOrderChanged(string poId) + { + if (ReadOnly || _model.PurchaseOrderId == poId) return; + _model.PurchaseOrderId = poId; + await RefreshItems(); + Snackbar.Add("Purchase Order reference changed. Payment summary and items have been updated.", Severity.Info); + } + + private async Task RefreshItems() + { + if (string.IsNullOrEmpty(_model.PurchaseOrderId)) return; + + try + { + var response = await BillService.GetPurchaseOrderDetailForBillAsync(_model.PurchaseOrderId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + _beforeTax = response.Value.BeforeTaxAmount ?? 0; + _taxAmount = response.Value.TaxAmount ?? 0; + _afterTax = response.Value.AfterTaxAmount ?? 0; + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + await Task.Delay(1000); + + var response = await BillService.GetBillByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = BillPdfGenerator.Generate(response.Value); + var fileName = $"BILL_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("PDF generated successfully.", Severity.Success); + } + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BillService.UpdateBillAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/CreateBillHandler.cs b/Features/Purchase/Bill/Cqrs/CreateBillHandler.cs new file mode 100644 index 0000000..bd9f02b --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/CreateBillHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class CreateBillRequest +{ + public DateTime? BillDate { get; set; } + public Data.Enums.BillStatus BillStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } +} + +public class CreateBillResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateBillCommand(CreateBillRequest Data) : IRequest; + +public class CreateBillHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateBillHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBillCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Bill); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"BILL/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Bill + { + AutoNumber = autoNo, + BillDate = request.Data.BillDate, + BillStatus = request.Data.BillStatus, + Description = request.Data.Description, + PurchaseOrderId = request.Data.PurchaseOrderId + }; + + _context.Bill.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBillResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/CreateBillValidator.cs b/Features/Purchase/Bill/Cqrs/CreateBillValidator.cs new file mode 100644 index 0000000..40fcdc9 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/CreateBillValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class CreateBillValidator : AbstractValidator +{ + public CreateBillValidator() + { + RuleFor(x => x.BillDate).NotEmpty().WithMessage("Bill Date is required"); + RuleFor(x => x.PurchaseOrderId).NotEmpty().WithMessage("Purchase Order is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/DeleteBillByIdHandler.cs b/Features/Purchase/Bill/Cqrs/DeleteBillByIdHandler.cs new file mode 100644 index 0000000..6f002f6 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/DeleteBillByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public record DeleteBillByIdCommand(string Id) : IRequest; + +public class DeleteBillByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteBillByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBillByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Bill + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.Bill.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/GetBillByIdHandler.cs b/Features/Purchase/Bill/Cqrs/GetBillByIdHandler.cs new file mode 100644 index 0000000..426603c --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/GetBillByIdHandler.cs @@ -0,0 +1,102 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class BillPurchaseOrderItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } + public decimal? Total { get; set; } +} + +public class GetBillByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? BillDate { get; set; } + public Data.Enums.BillStatus BillStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } + public string? PurchaseOrderAutoNumber { get; set; } + public string? VendorName { get; set; } + public string? VendorStreet { get; set; } + public string? VendorCity { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CurrencySymbol { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetBillByIdQuery(string Id) : IRequest; + +public class GetBillByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetBillByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBillByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.Bill + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .Include(x => x.PurchaseOrder) + .ThenInclude(po => po!.PurchaseOrderItemList) + .ThenInclude(i => i.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetBillByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + BillDate = x.BillDate, + BillStatus = x.BillStatus, + Description = x.Description, + PurchaseOrderId = x.PurchaseOrderId, + PurchaseOrderAutoNumber = x.PurchaseOrder!.AutoNumber, + VendorName = x.PurchaseOrder!.Vendor!.Name, + VendorStreet = x.PurchaseOrder!.Vendor!.Street, + VendorCity = x.PurchaseOrder!.Vendor!.City, + CompanyName = company != null ? company.Name : string.Empty, + CompanyStreet = company != null ? company.StreetAddress : string.Empty, + CompanyCity = company != null ? company.City : string.Empty, + CurrencySymbol = company != null && company.Currency != null ? company.Currency.Symbol : "IDR", + BeforeTaxAmount = x.PurchaseOrder!.BeforeTaxAmount, + TaxAmount = x.PurchaseOrder!.TaxAmount, + AfterTaxAmount = x.PurchaseOrder!.AfterTaxAmount, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Items = x.PurchaseOrder!.PurchaseOrderItemList.Select(i => new BillPurchaseOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/GetBillListHandler.cs b/Features/Purchase/Bill/Cqrs/GetBillListHandler.cs new file mode 100644 index 0000000..f5cacd5 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/GetBillListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class GetBillListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? BillDate { get; set; } + public string? PurchaseOrderAutoNumber { get; set; } + public string? VendorName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.BillStatus BillStatus { get; set; } +} + +public record GetBillListQuery() : IRequest>; + +public class GetBillListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetBillListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBillListQuery request, CancellationToken cancellationToken) + { + return await _context.Bill + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetBillListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + BillDate = x.BillDate, + PurchaseOrderAutoNumber = x.PurchaseOrder!.AutoNumber, + VendorName = x.PurchaseOrder!.Vendor!.Name, + AfterTaxAmount = x.PurchaseOrder!.AfterTaxAmount, + BillStatus = x.BillStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/GetBillLookupHandler.cs b/Features/Purchase/Bill/Cqrs/GetBillLookupHandler.cs new file mode 100644 index 0000000..dd176f7 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/GetBillLookupHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class BillLookupResponse +{ + public List PurchaseOrders { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetBillLookupQuery() : IRequest; + +public class GetBillLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetBillLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBillLookupQuery request, CancellationToken cancellationToken) + { + var response = new BillLookupResponse(); + + response.PurchaseOrders = await _context.PurchaseOrder.AsNoTracking() + .Where(x => x.OrderStatus == PurchaseOrderStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(BillStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/GetPurchaseOrderDetailForBillHandler.cs b/Features/Purchase/Bill/Cqrs/GetPurchaseOrderDetailForBillHandler.cs new file mode 100644 index 0000000..78cd813 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/GetPurchaseOrderDetailForBillHandler.cs @@ -0,0 +1,39 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public record GetPurchaseOrderDetailForBillQuery(string PurchaseOrderId) : IRequest; + +public class GetPurchaseOrderDetailForBillHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseOrderDetailForBillHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseOrderDetailForBillQuery request, CancellationToken cancellationToken) + { + return await _context.PurchaseOrder + .AsNoTracking() + .Include(x => x.PurchaseOrderItemList) + .ThenInclude(i => i.Product) + .Where(x => x.Id == request.PurchaseOrderId) + .Select(x => new GetBillByIdResponse + { + PurchaseOrderId = x.Id, + BeforeTaxAmount = x.BeforeTaxAmount, + TaxAmount = x.TaxAmount, + AfterTaxAmount = x.AfterTaxAmount, + Items = x.PurchaseOrderItemList.Select(i => new BillPurchaseOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/UpdateBillHandler.cs b/Features/Purchase/Bill/Cqrs/UpdateBillHandler.cs new file mode 100644 index 0000000..4d3809d --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/UpdateBillHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class UpdateBillRequest +{ + public string? Id { get; set; } + public DateTime? BillDate { get; set; } + public Data.Enums.BillStatus BillStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateBillCommand(UpdateBillRequest Data) : IRequest; + +public class UpdateBillHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateBillHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBillCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Bill + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.BillDate = request.Data.BillDate; + entity.BillStatus = request.Data.BillStatus; + entity.Description = request.Data.Description; + entity.PurchaseOrderId = request.Data.PurchaseOrderId; + + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/Bill/Cqrs/UpdateBillValidator.cs b/Features/Purchase/Bill/Cqrs/UpdateBillValidator.cs new file mode 100644 index 0000000..26fb2e2 --- /dev/null +++ b/Features/Purchase/Bill/Cqrs/UpdateBillValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.Bill.Cqrs; + +public class UpdateBillValidator : AbstractValidator +{ + public UpdateBillValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.PurchaseOrderId).NotEmpty().WithMessage("Purchase Order is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/BillReport/BillReportEndpoint.cs b/Features/Purchase/BillReport/BillReportEndpoint.cs new file mode 100644 index 0000000..10737c5 --- /dev/null +++ b/Features/Purchase/BillReport/BillReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.BillReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.BillReport; + +public static class BillReportEndpoint +{ + public static void MapBillReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/bill-report").WithTags("BillReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBillReportListQuery()); + return result.ToApiResponse("Bill report list retrieved successfully"); + }) + .WithName("GetBillReportList"); + } +} \ No newline at end of file diff --git a/Features/Purchase/BillReport/BillReportService.cs b/Features/Purchase/BillReport/BillReportService.cs new file mode 100644 index 0000000..8c5b2ff --- /dev/null +++ b/Features/Purchase/BillReport/BillReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.BillReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.BillReport; + +public class BillReportService : BaseService +{ + private readonly RestClient _client; + + public BillReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBillReportListAsync() + { + var request = new RestRequest("api/bill-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Purchase/BillReport/Components/BillReportPage.razor b/Features/Purchase/BillReport/Components/BillReportPage.razor new file mode 100644 index 0000000..74d1843 --- /dev/null +++ b/Features/Purchase/BillReport/Components/BillReportPage.razor @@ -0,0 +1,8 @@ +@page "/purchase/bill-report" +@using Indotalent.Features.Purchase.BillReport.Components +@using MudBlazor + +<_BillReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Purchase/BillReport/Components/_BillReportDataTable.razor b/Features/Purchase/BillReport/Components/_BillReportDataTable.razor new file mode 100644 index 0000000..b69f9ec --- /dev/null +++ b/Features/Purchase/BillReport/Components/_BillReportDataTable.razor @@ -0,0 +1,292 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.BillReport +@using Indotalent.Features.Purchase.BillReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BillReportService BillReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Bill Report + Summary of vendor billing, bill dates, and procurement status. +
+
+ + / + Purchase + / + Bill Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Vendor + + + Bill Number + + + Bill Date + + + Purchase Order + + + Total Amount + + + Status + + + + @context.VendorName + @context.Number + @context.BillDate?.ToString("yyyy-MM-dd") + @context.PurchaseOrderNumber + @context.AfterTaxAmount?.ToString("N2") + + @context.StatusName.ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await BillReportService.GetBillReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Number?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PurchaseOrderNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("BillReports"); + var currentRow = 1; + + string[] headers = { "Vendor", "Bill Number", "Bill Date", "Purchase Order", "Total Amount", "Status" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.VendorName; + worksheet.Cell(currentRow, 2).Value = item.Number; + worksheet.Cell(currentRow, 3).Value = item.BillDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 4).Value = item.PurchaseOrderNumber; + worksheet.Cell(currentRow, 5).Value = item.AfterTaxAmount; + worksheet.Cell(currentRow, 6).Value = item.StatusName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Bill_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Purchase/BillReport/Cqrs/GetBillReportListHandler.cs b/Features/Purchase/BillReport/Cqrs/GetBillReportListHandler.cs new file mode 100644 index 0000000..29996f2 --- /dev/null +++ b/Features/Purchase/BillReport/Cqrs/GetBillReportListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.BillReport.Cqrs; + +public record GetBillReportListDto +{ + public string? Id { get; init; } + public string? Number { get; init; } + public DateTime? BillDate { get; init; } + public string? StatusName { get; init; } + public string? PurchaseOrderNumber { get; init; } + public decimal? AfterTaxAmount { get; init; } + public string? VendorName { get; init; } +} + +public record GetBillReportListQuery() : IRequest>; + +public class GetBillReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBillReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetBillReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.Bill + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(x => x!.Vendor) + .OrderByDescending(x => x.BillDate) + .Select(x => new GetBillReportListDto + { + Id = x.Id, + Number = x.AutoNumber, + BillDate = x.BillDate, + StatusName = x.BillStatus.GetDescription(), + PurchaseOrderNumber = x.PurchaseOrder != null ? x.PurchaseOrder.AutoNumber : string.Empty, + AfterTaxAmount = x.PurchaseOrder != null ? x.PurchaseOrder.AfterTaxAmount : 0, + VendorName = (x.PurchaseOrder != null && x.PurchaseOrder.Vendor != null) ? x.PurchaseOrder.Vendor.Name : string.Empty + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Components/PaymentDisbursePage.razor b/Features/Purchase/PaymentDisburse/Components/PaymentDisbursePage.razor new file mode 100644 index 0000000..29ec758 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Components/PaymentDisbursePage.razor @@ -0,0 +1,51 @@ +@page "/purchase/payment-disburse" +@using Indotalent.Features.Purchase.PaymentDisburse.Cqrs +@using Indotalent.Features.Purchase.PaymentDisburse.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PaymentDisburseCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PaymentDisburseUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PaymentDisburseDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdatePaymentDisburseRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePaymentDisburseRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseCreateForm.razor b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseCreateForm.razor new file mode 100644 index 0000000..fe64c1c --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseCreateForm.razor @@ -0,0 +1,216 @@ +@using Indotalent.Features.Purchase.PaymentDisburse.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PaymentDisburseService PaymentDisburseService +@inject ISnackbar Snackbar + + +
+ +
+ Add Payment Disburse + Process payment to vendor for an existing bill. +
+
+
+ + + + + Basic Information + + + Payment Date + + + + + Bill Reference + + @foreach (var item in _lookup.Bills) + { + @item.Name + } + + + + + Payment Method + + @foreach (var item in _lookup.PaymentMethods) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Payment Amount + + + + + Vendor + + + + + Description + + + + + <_PaymentDisburseItemDataTable Items="_items" /> + + + + + + Bill Commercial Summary +
+ Sub Total + @_billSubTotal.ToString("N2") +
+
+ Tax Amount + @_billTax.ToString("N2") +
+ +
+ Grand Total + @_billGrandTotal.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Process Disburse + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePaymentDisburseRequest _model = new() { PaymentDate = DateTime.Today, Status = Indotalent.Data.Enums.PaymentDisburseStatus.Draft }; + private PaymentDisburseLookupResponse _lookup = new(); + private List _items = new(); + + private string _vendorName = ""; + private decimal _billSubTotal = 0; + private decimal _billTax = 0; + private decimal _billGrandTotal = 0; + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PaymentDisburseService.GetPaymentDisburseLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnBillChanged(string billId) + { + _model.BillId = billId; + if (string.IsNullOrEmpty(billId)) + { + ResetBillData(); + return; + } + + var res = await PaymentDisburseService.GetBillDetailForPaymentAsync(billId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _vendorName = res.Value.VendorName ?? ""; + _billSubTotal = res.Value.PurchaseOrderBeforeTaxAmount; + _billTax = res.Value.PurchaseOrderTaxAmount; + _billGrandTotal = res.Value.PurchaseOrderAfterTaxAmount; + + _model.PaymentAmount = res.Value.PaymentAmount; + + Snackbar.Add($"Bill {res.Value.BillNumber} items loaded.", Severity.Info); + } + } + + private void ResetBillData() + { + _items.Clear(); + _vendorName = ""; + _billSubTotal = 0; + _billTax = 0; + _billGrandTotal = 0; + _model.PaymentAmount = 0; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PaymentDisburseService.CreatePaymentDisburseAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Payment recorded successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseDataTable.razor b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseDataTable.razor new file mode 100644 index 0000000..aa9870b --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseDataTable.razor @@ -0,0 +1,382 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PaymentDisburse +@using Indotalent.Features.Purchase.PaymentDisburse.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PaymentDisburseService PaymentDisburseService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Payment Disburse + Manage and track payments made to vendors for bills. +
+
+ + / + Purchase + / + Payment Disburse +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Payment Disburse + + } +
+
+ + + + + + Payment No + + + Date + + + Bill Ref + + + Vendor + + + Amount + + + Status + + + + + + + @context.AutoNumber + @context.PaymentDate?.ToString("yyyy-MM-dd") + @context.BillNumber + @context.VendorName + @context.PaymentAmount.ToString("N2") + + @context.Status.ToString().ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetPaymentDisburseListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await PaymentDisburseService.GetPaymentDisburseListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.BillNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PaymentDisburses"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Payment No"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "Bill Ref"; + worksheet.Cell(currentRow, 4).Value = "Vendor"; + worksheet.Cell(currentRow, 5).Value = "Amount"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.PaymentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.BillNumber; + worksheet.Cell(currentRow, 4).Value = item.VendorName; + worksheet.Cell(currentRow, 5).Value = item.PaymentAmount; + worksheet.Cell(currentRow, 6).Value = item.Status.ToString(); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "PaymentDisburse_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await PaymentDisburseService.GetPaymentDisburseByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePaymentDisburseRequest + { + Id = detail.Id, + BillId = detail.BillId, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + PaymentDate = detail.PaymentDate, + PaymentMethodId = detail.PaymentMethodId, + PaymentAmount = detail.PaymentAmount, + Status = detail.Status, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await PaymentDisburseService.DeletePaymentDisburseByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseItemDataTable.razor b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseItemDataTable.razor new file mode 100644 index 0000000..f271c41 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseItemDataTable.razor @@ -0,0 +1,38 @@ +@using Indotalent.Features.Purchase.PaymentDisburse.Cqrs +@using MudBlazor + + +
+ Referenced Bill Items +
+ + + + Product + UnitPrice + Quantity + Total + + + + @context.ProductName + @if (!string.IsNullOrEmpty(context.Summary)) + { + @context.Summary + } + + @context.UnitPrice.ToString("N2") + @context.Quantity + @context.Total.ToString("N2") + + +
+ + + + +@code { + [Parameter] public List Items { get; set; } = new(); +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseUpdateForm.razor b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseUpdateForm.razor new file mode 100644 index 0000000..fb71e15 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Components/_PaymentDisburseUpdateForm.razor @@ -0,0 +1,302 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PaymentDisburse.Cqrs +@using Indotalent.Features.Purchase.PaymentDisburse.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PaymentDisburseService PaymentDisburseService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Payment Disburse + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating Voucher... + } + else + { + Print Voucher + } + + } +
+ + + + + Basic Information + + + Payment Date + + + + + Bill Reference + + @foreach (var item in _lookup.Bills) + { + @item.Name + } + + + + + Payment Method + + @foreach (var item in _lookup.PaymentMethods) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Payment Amount + + + + + Vendor + + + + + Description + + + + + <_PaymentDisburseItemDataTable Items="_items" /> + + + + + + Bill Commercial Summary +
+ Sub Total + @_billSubTotal.ToString("N2") +
+
+ Tax Amount + @_billTax.ToString("N2") +
+ +
+ Grand Total + @_billGrandTotal.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdatePaymentDisburseRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdatePaymentDisburseRequest _model = new(); + private List _items = new(); + private PaymentDisburseLookupResponse _lookup = new(); + + private string _vendorName = ""; + private decimal _billSubTotal = 0; + private decimal _billTax = 0; + private decimal _billGrandTotal = 0; + + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PaymentDisburseService.GetPaymentDisburseLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await LoadBillDetails(_model.BillId ?? ""); + } + + private async Task OnBillChanged(string billId) + { + if (ReadOnly || _model.BillId == billId) return; + _model.BillId = billId; + await LoadBillDetails(billId); + Snackbar.Add("Bill items and amounts updated.", Severity.Info); + } + + private async Task LoadBillDetails(string billId) + { + if (string.IsNullOrEmpty(billId)) return; + + try + { + var response = await PaymentDisburseService.GetBillDetailForPaymentAsync(billId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items; + _vendorName = response.Value.VendorName ?? ""; + _billSubTotal = response.Value.PurchaseOrderBeforeTaxAmount; + _billTax = response.Value.PurchaseOrderTaxAmount; + _billGrandTotal = response.Value.PurchaseOrderAfterTaxAmount; + + if (!ReadOnly) + { + _model.PaymentAmount = response.Value.PaymentAmount; + } + + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + await Task.Delay(1000); + + var response = await PaymentDisburseService.GetPaymentDisburseByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PaymentDisbursePdfGenerator.Generate(response.Value); + var fileName = $"Voucher_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("Voucher generated successfully.", Severity.Success); + } + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PaymentDisburseService.UpdatePaymentDisburseAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseHandler.cs new file mode 100644 index 0000000..c07aca8 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class CreatePaymentDisburseRequest +{ + public string? BillId { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public decimal? PaymentAmount { get; set; } + public Data.Enums.PaymentDisburseStatus Status { get; set; } +} + +public class CreatePaymentDisburseResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePaymentDisburseCommand(CreatePaymentDisburseRequest Data) : IRequest; + +public class CreatePaymentDisburseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePaymentDisburseHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePaymentDisburseCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PaymentDisburse); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"DISB/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PaymentDisburse + { + AutoNumber = autoNo, + BillId = request.Data.BillId, + Description = request.Data.Description, + PaymentDate = request.Data.PaymentDate, + PaymentMethodId = request.Data.PaymentMethodId, + PaymentAmount = request.Data.PaymentAmount ?? 0, + Status = request.Data.Status + }; + + _context.PaymentDisburse.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePaymentDisburseResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseValidator.cs b/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseValidator.cs new file mode 100644 index 0000000..c0265a3 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/CreatePaymentDisburseValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class CreatePaymentDisburseValidator : AbstractValidator +{ + public CreatePaymentDisburseValidator() + { + RuleFor(x => x.BillId).NotEmpty().WithMessage("Bill reference is required"); + RuleFor(x => x.PaymentDate).NotEmpty().WithMessage("Payment date is required"); + RuleFor(x => x.PaymentMethodId).NotEmpty().WithMessage("Payment method is required"); + RuleFor(x => x.PaymentAmount).GreaterThan(0).WithMessage("Amount must be greater than 0"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/DeletePaymentDisburseByIdHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/DeletePaymentDisburseByIdHandler.cs new file mode 100644 index 0000000..6ebc3fb --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/DeletePaymentDisburseByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public record DeletePaymentDisburseByIdCommand(string Id) : IRequest; + +public class DeletePaymentDisburseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePaymentDisburseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePaymentDisburseByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PaymentDisburse + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.PaymentDisburse.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/GetBillDetailForPaymentHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/GetBillDetailForPaymentHandler.cs new file mode 100644 index 0000000..5aed71e --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/GetBillDetailForPaymentHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public record GetBillDetailForPaymentQuery(string BillId) : IRequest; + +public class GetBillDetailForPaymentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetBillDetailForPaymentHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBillDetailForPaymentQuery request, CancellationToken cancellationToken) + { + var bill = await _context.Bill + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .Include(x => x.PurchaseOrder) + .ThenInclude(po => po!.PurchaseOrderItemList) + .ThenInclude(pi => pi.Product) + .FirstOrDefaultAsync(x => x.Id == request.BillId, cancellationToken); + + if (bill == null) return null; + + var purchaseOrder = bill.PurchaseOrder; + + return new GetPaymentDisburseByIdResponse + { + BillId = bill.Id, + BillNumber = bill.AutoNumber, + VendorName = purchaseOrder?.Vendor?.Name, + VendorStreet = purchaseOrder?.Vendor?.Street, + VendorCity = purchaseOrder?.Vendor?.City, + PurchaseOrderBeforeTaxAmount = purchaseOrder?.BeforeTaxAmount ?? 0, + PurchaseOrderTaxAmount = purchaseOrder?.TaxAmount ?? 0, + PurchaseOrderAfterTaxAmount = purchaseOrder?.AfterTaxAmount ?? 0, + PaymentAmount = purchaseOrder?.AfterTaxAmount ?? 0, + Items = purchaseOrder?.PurchaseOrderItemList.Select(x => new PaymentDisburseItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product?.Name, + Summary = x.Summary, + UnitPrice = x.UnitPrice ?? 0, + Quantity = x.Quantity ?? 0, + Total = x.Total ?? 0 + }).ToList() ?? new() + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseByIdHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseByIdHandler.cs new file mode 100644 index 0000000..8b381f9 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseByIdHandler.cs @@ -0,0 +1,116 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class PaymentDisburseItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal UnitPrice { get; set; } + public double Quantity { get; set; } + public decimal Total { get; set; } +} + +public class GetPaymentDisburseByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? BillId { get; set; } + public string? BillNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public string? PaymentMethodName { get; set; } + public decimal PaymentAmount { get; set; } + public Data.Enums.PaymentDisburseStatus Status { get; set; } + public string? VendorName { get; set; } + public string? VendorStreet { get; set; } + public string? VendorCity { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CurrencySymbol { get; set; } + public decimal PurchaseOrderBeforeTaxAmount { get; set; } + public decimal PurchaseOrderTaxAmount { get; set; } + public decimal PurchaseOrderAfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetPaymentDisburseByIdQuery(string Id) : IRequest; + +public class GetPaymentDisburseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPaymentDisburseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPaymentDisburseByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + var payment = await _context.PaymentDisburse + .AsNoTracking() + .Include(x => x.Bill) + .ThenInclude(b => b!.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .Include(x => x.Bill) + .ThenInclude(b => b!.PurchaseOrder) + .ThenInclude(po => po!.PurchaseOrderItemList) + .ThenInclude(pi => pi.Product) + .Include(x => x.PaymentMethod) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (payment == null) return null; + + var purchaseOrder = payment.Bill?.PurchaseOrder; + + return new GetPaymentDisburseByIdResponse + { + Id = payment.Id, + AutoNumber = payment.AutoNumber, + BillId = payment.BillId, + BillNumber = payment.Bill?.AutoNumber, + Description = payment.Description, + PaymentDate = payment.PaymentDate, + PaymentMethodId = payment.PaymentMethodId, + PaymentMethodName = payment.PaymentMethod?.Name, + PaymentAmount = payment.PaymentAmount ?? 0, + Status = payment.Status, + VendorName = purchaseOrder?.Vendor?.Name, + VendorStreet = purchaseOrder?.Vendor?.Street, + VendorCity = purchaseOrder?.Vendor?.City, + CompanyName = company?.Name, + CompanyStreet = company?.StreetAddress, + CompanyCity = company?.City, + CurrencySymbol = company?.Currency?.Symbol ?? "IDR", + PurchaseOrderBeforeTaxAmount = purchaseOrder?.BeforeTaxAmount ?? 0, + PurchaseOrderTaxAmount = purchaseOrder?.TaxAmount ?? 0, + PurchaseOrderAfterTaxAmount = purchaseOrder?.AfterTaxAmount ?? 0, + CreatedAt = payment.CreatedAt, + CreatedBy = payment.CreatedBy, + UpdatedAt = payment.UpdatedAt, + UpdatedBy = payment.UpdatedBy, + Items = purchaseOrder?.PurchaseOrderItemList.Select(x => new PaymentDisburseItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product?.Name, + Summary = x.Summary, + UnitPrice = x.UnitPrice ?? 0, + Quantity = x.Quantity ?? 0, + Total = x.Total ?? 0 + }).ToList() ?? new() + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseListHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseListHandler.cs new file mode 100644 index 0000000..7759d8d --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class GetPaymentDisburseListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? PaymentDate { get; set; } + public string? BillNumber { get; set; } + public string? VendorName { get; set; } + public decimal PaymentAmount { get; set; } + public Data.Enums.PaymentDisburseStatus Status { get; set; } +} + +public record GetPaymentDisburseListQuery() : IRequest>; + +public class GetPaymentDisburseListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPaymentDisburseListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPaymentDisburseListQuery request, CancellationToken cancellationToken) + { + return await _context.PaymentDisburse + .AsNoTracking() + .Include(x => x.Bill) + .ThenInclude(b => b!.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPaymentDisburseListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + PaymentDate = x.PaymentDate, + BillNumber = x.Bill!.AutoNumber, + VendorName = x.Bill!.PurchaseOrder!.Vendor!.Name, + PaymentAmount = x.PaymentAmount ?? 0, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseLookupHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseLookupHandler.cs new file mode 100644 index 0000000..4a7b816 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/GetPaymentDisburseLookupHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class PaymentDisburseLookupResponse +{ + public List Bills { get; set; } = new(); + public List PaymentMethods { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetPaymentDisburseLookupQuery() : IRequest; + +public class GetPaymentDisburseLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPaymentDisburseLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPaymentDisburseLookupQuery request, CancellationToken cancellationToken) + { + var response = new PaymentDisburseLookupResponse(); + + response.Bills = await _context.Bill.AsNoTracking() + .Where(x => x.BillStatus == BillStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.PaymentMethods = await _context.PaymentMethod.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(PaymentDisburseStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseHandler.cs b/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseHandler.cs new file mode 100644 index 0000000..67c5ee1 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class UpdatePaymentDisburseRequest +{ + public string? Id { get; set; } + public string? BillId { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public decimal? PaymentAmount { get; set; } + public Data.Enums.PaymentDisburseStatus Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdatePaymentDisburseCommand(UpdatePaymentDisburseRequest Data) : IRequest; + +public class UpdatePaymentDisburseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePaymentDisburseHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePaymentDisburseCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PaymentDisburse + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.BillId = request.Data.BillId; + entity.Description = request.Data.Description; + entity.PaymentDate = request.Data.PaymentDate; + entity.PaymentMethodId = request.Data.PaymentMethodId; + entity.PaymentAmount = request.Data.PaymentAmount ?? 0; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseValidator.cs b/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseValidator.cs new file mode 100644 index 0000000..e30d92a --- /dev/null +++ b/Features/Purchase/PaymentDisburse/Cqrs/UpdatePaymentDisburseValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +public class UpdatePaymentDisburseValidator : AbstractValidator +{ + public UpdatePaymentDisburseValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.BillId).NotEmpty().WithMessage("Bill reference is required"); + RuleFor(x => x.PaymentDate).NotEmpty().WithMessage("Payment date is required"); + RuleFor(x => x.PaymentAmount).GreaterThan(0).WithMessage("Amount must be greater than 0"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/PaymentDisburseEndpoint.cs b/Features/Purchase/PaymentDisburse/PaymentDisburseEndpoint.cs new file mode 100644 index 0000000..8faaec2 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/PaymentDisburseEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PaymentDisburse.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PaymentDisburse; + +public static class PaymentDisburseEndpoint +{ + public static void MapPaymentDisburseEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/payment-disburse").WithTags("PaymentDisburses") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentDisburseListQuery()); + return result.ToApiResponse("Payment disburse list retrieved successfully"); + }) + .WithName("GetPaymentDisburseList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentDisburseByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Payment disburse detail retrieved successfully" + : $"Payment disburse with ID {id} not found"); + }) + .WithName("GetPaymentDisburseById"); + + group.MapGet("/bill-detail/{billId}", async (string billId, IMediator mediator) => + { + var result = await mediator.Send(new GetBillDetailForPaymentQuery(billId)); + return result.ToApiResponse(result is not null + ? "Bill detail for payment retrieved successfully" + : $"Bill with ID {billId} not found"); + }) + .WithName("GetBillDetailForPayment"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentDisburseLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPaymentDisburseLookup"); + + group.MapPost("/", async (CreatePaymentDisburseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePaymentDisburseCommand(request)); + return result.ToApiResponse("Payment disburse has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePaymentDisburse"); + + group.MapPost("/update", async (UpdatePaymentDisburseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePaymentDisburseCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Payment disburse not found."); + } + return result.ToApiResponse("Payment disburse has been updated successfully"); + }) + .WithName("UpdatePaymentDisburse"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePaymentDisburseByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Payment disburse not found."); + } + return true.ToApiResponse("Payment disburse has been deleted successfully"); + }) + .WithName("DeletePaymentDisburseById"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/PaymentDisbursePdfGenerator.cs b/Features/Purchase/PaymentDisburse/PaymentDisbursePdfGenerator.cs new file mode 100644 index 0000000..048a6d8 --- /dev/null +++ b/Features/Purchase/PaymentDisburse/PaymentDisbursePdfGenerator.cs @@ -0,0 +1,127 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.PaymentDisburse.Cqrs; + +namespace Indotalent.Features.Purchase.PaymentDisburse; + +public class PaymentDisbursePdfGenerator +{ + public static byte[] Generate(GetPaymentDisburseByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("PAYMENT VOUCHER") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("PAID TO (VENDOR)").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.VendorName).Bold(); + c.Item().Text(data.VendorStreet); + c.Item().Text(data.VendorCity); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("PAYMENT INFO").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Date"); + r.RelativeItem().AlignRight().Text(data.PaymentDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Method"); + r.RelativeItem().AlignRight().Text(data.PaymentMethodName); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Bill Ref"); + r.RelativeItem().AlignRight().Text(data.BillNumber).Bold(); + }); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(6); + columns.RelativeColumn(3); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Item Details"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Amount ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)).PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total.ToString("N2")); + + static IContainer CellStyle(IContainer container) => container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Description:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { r.RelativeItem().Text("Sub Total"); r.RelativeItem().AlignRight().Text(data.PurchaseOrderBeforeTaxAmount.ToString("N2")); }); + c.Item().Row(r => { r.RelativeItem().Text("Tax Amount"); r.RelativeItem().AlignRight().Text(data.PurchaseOrderTaxAmount.ToString("N2")); }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { r.RelativeItem().Text($"TOTAL BILL").Bold(); r.RelativeItem().AlignRight().Text(data.PurchaseOrderAfterTaxAmount.ToString("N2")).ExtraBold(); }); + c.Item().PaddingTop(5).Background("#F1F5F9").PaddingHorizontal(5).Row(r => { r.RelativeItem().Text($"DISBURSED").Bold().FontColor(primaryBlue); r.RelativeItem().AlignRight().Text(data.PaymentAmount.ToString("N2")).ExtraBold().FontColor(primaryBlue); }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => { x.Span("Page ").FontSize(8); x.CurrentPageNumber().FontSize(8); }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburse/PaymentDisburseService.cs b/Features/Purchase/PaymentDisburse/PaymentDisburseService.cs new file mode 100644 index 0000000..6e263ef --- /dev/null +++ b/Features/Purchase/PaymentDisburse/PaymentDisburseService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PaymentDisburse.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PaymentDisburse; + +public class PaymentDisburseService : BaseService +{ + private readonly RestClient _client; + + public PaymentDisburseService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPaymentDisburseListAsync() + { + var request = new RestRequest("api/payment-disburse", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPaymentDisburseByIdAsync(string id) + { + var request = new RestRequest($"api/payment-disburse/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetBillDetailForPaymentAsync(string billId) + { + var request = new RestRequest($"api/payment-disburse/bill-detail/{billId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPaymentDisburseLookupAsync() + { + var request = new RestRequest("api/payment-disburse/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePaymentDisburseAsync(CreatePaymentDisburseRequest data) + { + var request = new RestRequest("api/payment-disburse", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePaymentDisburseAsync(UpdatePaymentDisburseRequest data) + { + var request = new RestRequest("api/payment-disburse/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePaymentDisburseByIdAsync(string id) + { + var request = new RestRequest($"api/payment-disburse/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburseReport/Components/PaymentDisburseReportPage.razor b/Features/Purchase/PaymentDisburseReport/Components/PaymentDisburseReportPage.razor new file mode 100644 index 0000000..926c679 --- /dev/null +++ b/Features/Purchase/PaymentDisburseReport/Components/PaymentDisburseReportPage.razor @@ -0,0 +1,8 @@ +@page "/purchase/payment-disburse-report" +@using Indotalent.Features.Purchase.PaymentDisburseReport.Components +@using MudBlazor + +<_PaymentDisburseReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburseReport/Components/_PaymentDisburseReportDataTable.razor b/Features/Purchase/PaymentDisburseReport/Components/_PaymentDisburseReportDataTable.razor new file mode 100644 index 0000000..0b4f3b9 --- /dev/null +++ b/Features/Purchase/PaymentDisburseReport/Components/_PaymentDisburseReportDataTable.razor @@ -0,0 +1,297 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PaymentDisburseReport +@using Indotalent.Features.Purchase.PaymentDisburseReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PaymentDisburseReportService PaymentDisburseReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Payment Disburse Report + Detailed log of all outgoing vendor payments and debt settlements. +
+
+ + / + Purchase + / + Payment Disburse Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Vendor + + + Payment No + + + Payment Date + + + Bill + + + Method + + + Amount + + + Status + + + + @context.VendorName + @context.Number + @context.PaymentDate?.ToString("yyyy-MM-dd") + @context.BillNumber + @context.PaymentMethodName + @context.PaymentAmount?.ToString("N2") + + @context.StatusName.ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await PaymentDisburseReportService.GetPaymentDisburseReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Number?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.BillNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PaymentDisburseReports"); + var currentRow = 1; + + string[] headers = { "Vendor", "Payment No", "Payment Date", "Bill", "Method", "Amount", "Status" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.VendorName; + worksheet.Cell(currentRow, 2).Value = item.Number; + worksheet.Cell(currentRow, 3).Value = item.PaymentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 4).Value = item.BillNumber; + worksheet.Cell(currentRow, 5).Value = item.PaymentMethodName; + worksheet.Cell(currentRow, 6).Value = item.PaymentAmount; + worksheet.Cell(currentRow, 7).Value = item.StatusName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Payment_Disburse_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburseReport/Cqrs/GetPaymentDisburseReportListHandler.cs b/Features/Purchase/PaymentDisburseReport/Cqrs/GetPaymentDisburseReportListHandler.cs new file mode 100644 index 0000000..a3cd9a6 --- /dev/null +++ b/Features/Purchase/PaymentDisburseReport/Cqrs/GetPaymentDisburseReportListHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PaymentDisburseReport.Cqrs; + +public record GetPaymentDisburseReportListDto +{ + public string? Id { get; init; } + public string? Number { get; init; } + public DateTime? PaymentDate { get; init; } + public string? PaymentMethodName { get; init; } + public decimal? PaymentAmount { get; init; } + public string? StatusName { get; init; } + public string? BillNumber { get; init; } + public string? VendorName { get; init; } +} + +public record GetPaymentDisburseReportListQuery() : IRequest>; + +public class GetPaymentDisburseReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPaymentDisburseReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetPaymentDisburseReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.PaymentDisburse + .AsNoTracking() + .Include(x => x.PaymentMethod) + .Include(x => x.Bill) + .ThenInclude(x => x!.PurchaseOrder) + .ThenInclude(x => x!.Vendor) + .OrderByDescending(x => x.PaymentDate) + .Select(x => new GetPaymentDisburseReportListDto + { + Id = x.Id, + Number = x.AutoNumber, + PaymentDate = x.PaymentDate, + PaymentMethodName = x.PaymentMethod != null ? x.PaymentMethod.Name : string.Empty, + PaymentAmount = x.PaymentAmount, + StatusName = x.Status.GetDescription(), + BillNumber = x.Bill != null ? x.Bill.AutoNumber : string.Empty, + VendorName = (x.Bill != null && x.Bill.PurchaseOrder != null && x.Bill.PurchaseOrder.Vendor != null) + ? x.Bill.PurchaseOrder.Vendor.Name : string.Empty + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportEndpoint.cs b/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportEndpoint.cs new file mode 100644 index 0000000..d7caac7 --- /dev/null +++ b/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PaymentDisburseReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PaymentDisburseReport; + +public static class PaymentDisburseReportEndpoint +{ + public static void MapPaymentDisburseReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/payment-disburse-report").WithTags("PaymentDisburseReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentDisburseReportListQuery()); + return result.ToApiResponse("Payment disburse report list retrieved successfully"); + }) + .WithName("GetPaymentDisburseReportList"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportService.cs b/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportService.cs new file mode 100644 index 0000000..40cf939 --- /dev/null +++ b/Features/Purchase/PaymentDisburseReport/PaymentDisburseReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PaymentDisburseReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PaymentDisburseReport; + +public class PaymentDisburseReportService : BaseService +{ + private readonly RestClient _client; + + public PaymentDisburseReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPaymentDisburseReportListAsync() + { + var request = new RestRequest("api/payment-disburse-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/PurchaseOrderPage.razor b/Features/Purchase/PurchaseOrder/Components/PurchaseOrderPage.razor new file mode 100644 index 0000000..666cf0b --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/PurchaseOrderPage.razor @@ -0,0 +1,51 @@ +@page "/purchase/purchase-order" +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using Indotalent.Features.Purchase.PurchaseOrder.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PurchaseOrderCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PurchaseOrderUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PurchaseOrderDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdatePurchaseOrderRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePurchaseOrderRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderCreateForm.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderCreateForm.razor new file mode 100644 index 0000000..c475fc4 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderCreateForm.razor @@ -0,0 +1,141 @@ +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PurchaseOrderService PurchaseOrderService +@inject ISnackbar Snackbar + + +
+ +
+ Add Purchase Order + Create a new master purchase order. +
+
+
+ + + + + Basic Information + + + Order Date + + + + + Vendor + + @foreach (var item in _lookup.Vendors) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Tax + + @foreach (var item in _lookup.Taxes) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Order + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePurchaseOrderValidator _validator = new(); + private CreatePurchaseOrderRequest _model = new() { OrderDate = DateTime.Today }; + private PurchaseOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseOrderService.GetPurchaseOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PurchaseOrderService.CreatePurchaseOrderAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderDataTable.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderDataTable.razor new file mode 100644 index 0000000..5e01a8d --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderDataTable.razor @@ -0,0 +1,373 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseOrder +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PurchaseOrderService PurchaseOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Purchase Order + Manage vendor orders and procurement tracking. +
+
+ + / + Purchase + / + Purchase Order +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Purchase Order + + } +
+
+ + + + + + No + + + Date + + + Vendor + + + Status + + + Total Amount + + + + + + + @context.AutoNumber + @context.OrderDate?.ToString("yyyy-MM-dd") + @context.VendorName + + @context.OrderStatus.ToString().ToUpper() + + @context.AfterTaxAmount?.ToString("N2") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetPurchaseOrderListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await PurchaseOrderService.GetPurchaseOrderListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PurchaseOrders"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Auto Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "Vendor"; + worksheet.Cell(currentRow, 4).Value = "Status"; + worksheet.Cell(currentRow, 5).Value = "Total Amount"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.OrderDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.VendorName; + worksheet.Cell(currentRow, 4).Value = item.OrderStatus.ToString(); + worksheet.Cell(currentRow, 5).Value = item.AfterTaxAmount; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "PO_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await PurchaseOrderService.GetPurchaseOrderByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePurchaseOrderRequest + { + Id = detail.Id, + OrderDate = detail.OrderDate, + OrderStatus = detail.OrderStatus, + Description = detail.Description, + VendorId = detail.VendorId, + TaxId = detail.TaxId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await PurchaseOrderService.DeletePurchaseOrderByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemCreateForm.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemCreateForm.razor new file mode 100644 index 0000000..b033e92 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemCreateForm.razor @@ -0,0 +1,86 @@ +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using MudBlazor +@inject PurchaseOrderService PurchaseOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Unit Price + + + + Quantity + + + + Summary + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string PurchaseOrderId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreatePurchaseOrderItemRequest _model = new() { Quantity = 1 }; + private PurchaseOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseOrderService.GetPurchaseOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.PurchaseOrderId = PurchaseOrderId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PurchaseOrderService.CreatePurchaseOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Item added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemDataTable.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemDataTable.razor new file mode 100644 index 0000000..2a8138f --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemDataTable.razor @@ -0,0 +1,103 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseOrder +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject PurchaseOrderService PurchaseOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + UnitPrice + Quantity + Total + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.UnitPrice?.ToString("N2") + @context.Quantity + @context.Total?.ToString("N2") + @if (!ReadOnly) + { + + + + + } + + +
+ + + + + + +@code { + [Parameter] public string PurchaseOrderId { get; set; } = string.Empty; + [Parameter] public List Items { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["PurchaseOrderId"] = PurchaseOrderId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseOrderItemCreateForm>("Add Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(PurchaseOrderItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseOrderItemUpdateForm>("Edit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(PurchaseOrderItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this item?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await PurchaseOrderService.DeletePurchaseOrderItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemUpdateForm.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemUpdateForm.razor new file mode 100644 index 0000000..210eb97 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderItemUpdateForm.razor @@ -0,0 +1,96 @@ +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using MudBlazor +@inject PurchaseOrderService PurchaseOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Unit Price + + + + Quantity + + + + Summary + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public PurchaseOrderItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdatePurchaseOrderItemRequest _model = new(); + private PurchaseOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseOrderService.GetPurchaseOrderLookupAsync(); + if (res != null && res.IsSuccess) + { + _lookup = res.Value ?? new(); + } + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.Summary = Data.Summary; + _model.UnitPrice = Data.UnitPrice; + _model.Quantity = Data.Quantity; + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PurchaseOrderService.UpdatePurchaseOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Item updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderUpdateForm.razor b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderUpdateForm.razor new file mode 100644 index 0000000..40a5a2b --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Components/_PurchaseOrderUpdateForm.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseOrder.Cqrs +@using Indotalent.Features.Purchase.PurchaseOrder.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PurchaseOrderService PurchaseOrderService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Order + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Order Date + + + + + Vendor + + @foreach (var item in _lookup.Vendors) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Tax + + @foreach (var item in _lookup.Taxes) + { + @item.Name + } + + + + + Description + + + + + <_PurchaseOrderItemDataTable Items="_items" PurchaseOrderId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Payment Summary +
+ Sub Total + @_model.BeforeTaxAmount?.ToString("N2") +
+
+ Tax Amount + @_model.TaxAmount?.ToString("N2") +
+ +
+ Total Amount + @_model.AfterTaxAmount?.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdatePurchaseOrderRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdatePurchaseOrderValidator _validator = new(); + private UpdatePurchaseOrderRequest _model = new(); + private List _items = new(); + private PurchaseOrderLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PurchaseOrderService.GetPurchaseOrderLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnTaxChanged(string newTaxId) + { + if (ReadOnly || _model.TaxId == newTaxId) return; + + _model.TaxId = newTaxId; + StateHasChanged(); + + await SubmitInternal(false); + Snackbar.Add("Tax updated and totals recalculated.", Severity.Info); + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await PurchaseOrderService.GetPurchaseOrderByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + _model.BeforeTaxAmount = response.Value.BeforeTaxAmount; + _model.TaxAmount = response.Value.TaxAmount; + _model.AfterTaxAmount = response.Value.AfterTaxAmount; + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + + await Task.Delay(1000); + + var response = await PurchaseOrderService.GetPurchaseOrderByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PurchaseOrderPdfGenerator.Generate(response.Value); + var fileName = $"PO_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("PDF generated successfully.", Severity.Success); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await SubmitInternal(true); + } + + private async Task SubmitInternal(bool showNotification) + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await PurchaseOrderService.UpdatePurchaseOrderAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + if (showNotification) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderHandler.cs new file mode 100644 index 0000000..d043266 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class CreatePurchaseOrderRequest +{ + public DateTime? OrderDate { get; set; } + public Data.Enums.PurchaseOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public string? TaxId { get; set; } +} + +public class CreatePurchaseOrderResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePurchaseOrderCommand(CreatePurchaseOrderRequest Data) : IRequest; + +public class CreatePurchaseOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PurchaseOrder); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"PO/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PurchaseOrder + { + AutoNumber = autoNo, + OrderDate = request.Data.OrderDate, + OrderStatus = request.Data.OrderStatus, + Description = request.Data.Description, + VendorId = request.Data.VendorId, + TaxId = request.Data.TaxId, + BeforeTaxAmount = 0, + TaxAmount = 0, + AfterTaxAmount = 0 + }; + + _context.PurchaseOrder.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePurchaseOrderResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderItemHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderItemHandler.cs new file mode 100644 index 0000000..cc6125f --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderItemHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class CreatePurchaseOrderItemRequest +{ + public string? PurchaseOrderId { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record CreatePurchaseOrderItemCommand(CreatePurchaseOrderItemRequest Data) : IRequest; + +public class CreatePurchaseOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.PurchaseOrderItem + { + PurchaseOrderId = request.Data.PurchaseOrderId, + ProductId = request.Data.ProductId, + Summary = request.Data.Summary, + UnitPrice = request.Data.UnitPrice ?? 0, + Quantity = request.Data.Quantity ?? 0, + Total = (request.Data.UnitPrice ?? 0) * (decimal)(request.Data.Quantity ?? 0) + }; + + _context.PurchaseOrderItem.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(request.Data.PurchaseOrderId)) + { + PurchaseOrderHelper.Recalculate(_context, request.Data.PurchaseOrderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderValidator.cs b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderValidator.cs new file mode 100644 index 0000000..7dc8d68 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/CreatePurchaseOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class CreatePurchaseOrderValidator : AbstractValidator +{ + public CreatePurchaseOrderValidator() + { + RuleFor(x => x.OrderDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.VendorId).NotEmpty().WithMessage("Vendor is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderByIdHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderByIdHandler.cs new file mode 100644 index 0000000..4e6af97 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public record DeletePurchaseOrderByIdCommand(string Id) : IRequest; + +public class DeletePurchaseOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseOrderByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseOrder + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.PurchaseOrder.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderItemHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderItemHandler.cs new file mode 100644 index 0000000..4b72a88 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/DeletePurchaseOrderItemHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public record DeletePurchaseOrderItemCommand(string Id) : IRequest; + +public class DeletePurchaseOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseOrderItem + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var orderId = entity.PurchaseOrderId; + + _context.PurchaseOrderItem.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(orderId)) + { + PurchaseOrderHelper.Recalculate(_context, orderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderByIdHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderByIdHandler.cs new file mode 100644 index 0000000..f7fa76c --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderByIdHandler.cs @@ -0,0 +1,118 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class PurchaseOrderItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } + public decimal? Total { get; set; } +} + +public class GetPurchaseOrderByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public Data.Enums.PurchaseOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public string? VendorName { get; set; } + public string? VendorStreet { get; set; } + public string? VendorCity { get; set; } + public string? VendorState { get; set; } + public string? VendorZipCode { get; set; } + public string? VendorPhone { get; set; } + public string? VendorEmail { get; set; } + public string? VendorFax { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CompanyState { get; set; } + public string? CompanyZipCode { get; set; } + public string? CompanyPhone { get; set; } + public string? CompanyEmail { get; set; } + public string? CurrencySymbol { get; set; } + public string? TaxId { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetPurchaseOrderByIdQuery(string Id) : IRequest; + +public class GetPurchaseOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseOrderByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.PurchaseOrder + .AsNoTracking() + .Include(x => x.Vendor) + .Include(x => x.PurchaseOrderItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetPurchaseOrderByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + OrderDate = x.OrderDate, + OrderStatus = x.OrderStatus, + Description = x.Description, + VendorId = x.VendorId, + VendorName = x.Vendor!.Name, + VendorStreet = x.Vendor!.Street, + VendorCity = x.Vendor!.City, + VendorState = x.Vendor!.State, + VendorZipCode = x.Vendor!.ZipCode, + VendorPhone = x.Vendor!.PhoneNumber, + VendorEmail = x.Vendor!.EmailAddress, + VendorFax = x.Vendor!.FaxNumber, + CompanyName = company != null ? company.Name : string.Empty, + CompanyStreet = company != null ? company.StreetAddress : string.Empty, + CompanyCity = company != null ? company.City : string.Empty, + CompanyState = company != null ? company.StateProvince : string.Empty, + CompanyZipCode = company != null ? company.ZipCode : string.Empty, + CompanyPhone = company != null ? company.Phone : string.Empty, + CompanyEmail = company != null ? company.Email : string.Empty, + CurrencySymbol = company != null && company.Currency != null ? company.Currency.Symbol : "IDR", + TaxId = x.TaxId, + BeforeTaxAmount = x.BeforeTaxAmount, + TaxAmount = x.TaxAmount, + AfterTaxAmount = x.AfterTaxAmount, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Items = x.PurchaseOrderItemList.Select(i => new PurchaseOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderListHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderListHandler.cs new file mode 100644 index 0000000..286212c --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class GetPurchaseOrderListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public string? VendorName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.PurchaseOrderStatus OrderStatus { get; set; } +} + +public record GetPurchaseOrderListQuery() : IRequest>; + +public class GetPurchaseOrderListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPurchaseOrderListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPurchaseOrderListQuery request, CancellationToken cancellationToken) + { + return await _context.PurchaseOrder + .AsNoTracking() + .Include(x => x.Vendor) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPurchaseOrderListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + OrderDate = x.OrderDate, + VendorName = x.Vendor!.Name, + AfterTaxAmount = x.AfterTaxAmount, + OrderStatus = x.OrderStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderLookupHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderLookupHandler.cs new file mode 100644 index 0000000..aca858c --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/GetPurchaseOrderLookupHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class PurchaseOrderLookupResponse +{ + public List Vendors { get; set; } = new(); + public List Taxes { get; set; } = new(); + public List Products { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetPurchaseOrderLookupQuery() : IRequest; + +public class GetPurchaseOrderLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseOrderLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseOrderLookupQuery request, CancellationToken cancellationToken) + { + var response = new PurchaseOrderLookupResponse(); + + response.Vendors = await _context.Vendor.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Taxes = await _context.Tax.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(PurchaseOrderStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderHandler.cs new file mode 100644 index 0000000..d378406 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class UpdatePurchaseOrderRequest +{ + public string? Id { get; set; } + public DateTime? OrderDate { get; set; } + public Data.Enums.PurchaseOrderStatus OrderStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public string? TaxId { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdatePurchaseOrderCommand(UpdatePurchaseOrderRequest Data) : IRequest; + +public class UpdatePurchaseOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseOrderCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseOrder + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.OrderDate = request.Data.OrderDate; + entity.OrderStatus = request.Data.OrderStatus; + entity.Description = request.Data.Description; + entity.VendorId = request.Data.VendorId; + entity.TaxId = request.Data.TaxId; + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.Id)) + { + PurchaseOrderHelper.Recalculate(_context, entity.Id); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderItemHandler.cs b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderItemHandler.cs new file mode 100644 index 0000000..ba9d222 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderItemHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class UpdatePurchaseOrderItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record UpdatePurchaseOrderItemCommand(UpdatePurchaseOrderItemRequest Data) : IRequest; + +public class UpdatePurchaseOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseOrderItem + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.Summary = request.Data.Summary; + entity.UnitPrice = request.Data.UnitPrice ?? 0; + entity.Quantity = request.Data.Quantity ?? 0; + entity.Total = (request.Data.UnitPrice ?? 0) * (decimal)(request.Data.Quantity ?? 0); + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.PurchaseOrderId)) + { + PurchaseOrderHelper.Recalculate(_context, entity.PurchaseOrderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderValidator.cs b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderValidator.cs new file mode 100644 index 0000000..8182bc2 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/Cqrs/UpdatePurchaseOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +public class UpdatePurchaseOrderValidator : AbstractValidator +{ + public UpdatePurchaseOrderValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.VendorId).NotEmpty().WithMessage("Vendor is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/PurchaseOrderEndpoint.cs b/Features/Purchase/PurchaseOrder/PurchaseOrderEndpoint.cs new file mode 100644 index 0000000..4017e4c --- /dev/null +++ b/Features/Purchase/PurchaseOrder/PurchaseOrderEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PurchaseOrder.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PurchaseOrder; + +public static class PurchaseOrderEndpoint +{ + public static void MapPurchaseOrderEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/purchase-order").WithTags("PurchaseOrders") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseOrderListQuery()); + return result.ToApiResponse("Purchase order list retrieved successfully"); + }) + .WithName("GetPurchaseOrderList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseOrderByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Purchase order detail retrieved successfully" + : $"Purchase order with ID {id} not found"); + }) + .WithName("GetPurchaseOrderById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseOrderLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPurchaseOrderLookup"); + + group.MapPost("/", async (CreatePurchaseOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseOrderCommand(request)); + return result.ToApiResponse("Purchase order has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePurchaseOrder"); + + group.MapPost("/update", async (UpdatePurchaseOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseOrderCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Purchase order not found."); + } + return result.ToApiResponse("Purchase order has been updated successfully"); + }) + .WithName("UpdatePurchaseOrder"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseOrderByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Purchase order not found."); + } + return true.ToApiResponse("Purchase order has been deleted successfully"); + }) + .WithName("DeletePurchaseOrderById"); + + group.MapPost("/purchase-order-item", async (CreatePurchaseOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseOrderItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreatePurchaseOrderItem"); + + group.MapPost("/purchase-order-item/update", async (UpdatePurchaseOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseOrderItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdatePurchaseOrderItem"); + + group.MapPost("/purchase-order-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseOrderItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeletePurchaseOrderItem"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/PurchaseOrderPdfGenerator.cs b/Features/Purchase/PurchaseOrder/PurchaseOrderPdfGenerator.cs new file mode 100644 index 0000000..a6b20cd --- /dev/null +++ b/Features/Purchase/PurchaseOrder/PurchaseOrderPdfGenerator.cs @@ -0,0 +1,153 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.PurchaseOrder.Cqrs; + +namespace Indotalent.Features.Purchase.PurchaseOrder; + +public class PurchaseOrderPdfGenerator +{ + public static byte[] Generate(GetPurchaseOrderByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("PURCHASE ORDER") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}, {data.CompanyState} {data.CompanyZipCode}").FontColor(textSlate); + col.Item().Text($"Tel: {data.CompanyPhone} | Email: {data.CompanyEmail}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("FROM (COMPANY)").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.CompanyName).Bold(); + c.Item().Text(data.CompanyStreet); + c.Item().Text($"{data.CompanyCity}, {data.CompanyState} {data.CompanyZipCode}"); + c.Item().Text($"Tel: {data.CompanyPhone}"); + c.Item().Text($"Email: {data.CompanyEmail}"); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("TO (VENDOR)").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.VendorName).Bold(); + c.Item().Text(data.VendorStreet); + c.Item().Text($"{data.VendorCity}, {data.VendorState} {data.VendorZipCode}"); + c.Item().Text($"Tel: {data.VendorPhone}"); + if (!string.IsNullOrEmpty(data.VendorFax)) c.Item().Text($"Fax: {data.VendorFax}"); + c.Item().Text($"Email: {data.VendorEmail}"); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(4); + columns.RelativeColumn(2); + columns.RelativeColumn(1.5f); + columns.RelativeColumn(2.5f); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Description"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Unit Price"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Total ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) + { + return container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)) + .PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + } + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.UnitPrice?.ToString("N2")); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Quantity?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total?.ToString("N2")); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Notes:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + c.Item().PaddingTop(10).Text($"Date: {data.OrderDate?.ToString("dd MMM yyyy")}").FontSize(9); + c.Item().Text($"Status: {data.OrderStatus.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryBlue); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { + r.RelativeItem().Text("Sub Total"); + r.RelativeItem().AlignRight().Text(data.BeforeTaxAmount?.ToString("N2")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Tax Amount"); + r.RelativeItem().AlignRight().Text(data.TaxAmount?.ToString("N2")); + }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { + r.RelativeItem().Text($"TOTAL ({data.CurrencySymbol})").Bold(); + r.RelativeItem().AlignRight().Text(data.AfterTaxAmount?.ToString("N2")).ExtraBold().FontColor(primaryBlue); + }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => + { + x.Span("Page ").FontSize(8); + x.CurrentPageNumber().FontSize(8); + x.Span(" of ").FontSize(8); + x.TotalPages().FontSize(8); + }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseOrder/PurchaseOrderService.cs b/Features/Purchase/PurchaseOrder/PurchaseOrderService.cs new file mode 100644 index 0000000..5d4fa58 --- /dev/null +++ b/Features/Purchase/PurchaseOrder/PurchaseOrderService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PurchaseOrder.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PurchaseOrder; + +public class PurchaseOrderService : BaseService +{ + private readonly RestClient _client; + + public PurchaseOrderService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPurchaseOrderListAsync() + { + var request = new RestRequest("api/purchase-order", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPurchaseOrderByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-order/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPurchaseOrderLookupAsync() + { + var request = new RestRequest("api/purchase-order/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePurchaseOrderAsync(CreatePurchaseOrderRequest data) + { + var request = new RestRequest("api/purchase-order", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseOrderAsync(UpdatePurchaseOrderRequest data) + { + var request = new RestRequest("api/purchase-order/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseOrderByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-order/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreatePurchaseOrderItemAsync(CreatePurchaseOrderItemRequest data) + { + var request = new RestRequest("api/purchase-order/purchase-order-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseOrderItemAsync(UpdatePurchaseOrderItemRequest data) + { + var request = new RestRequest("api/purchase-order/purchase-order-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseOrderItemAsync(string id) + { + var request = new RestRequest($"api/purchase-order/purchase-order-item/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchasePage.razor b/Features/Purchase/PurchasePage.razor new file mode 100644 index 0000000..4c73952 --- /dev/null +++ b/Features/Purchase/PurchasePage.razor @@ -0,0 +1,112 @@ +@page "/purchase" +@using Indotalent.Features.Purchase.Bill.Components +@using Indotalent.Features.Purchase.BillReport.Components +@using Indotalent.Features.Purchase.PaymentDisburse.Components +@using Indotalent.Features.Purchase.PaymentDisburseReport.Components +@using Indotalent.Features.Purchase.PurchaseOrder.Components +@using Indotalent.Features.Purchase.PurchaseReport.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "order" }, + { 1, "bill" }, + { 2, "payment" }, + { 3, "report-purchase" }, + { 4, "report-bill" }, + { 5, "report-payment" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + _activeTabIndex = match.Value != null ? match.Key : 0; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/purchase?tab={tabName ?? "requisition"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReport/Components/PurchaseReportPage.razor b/Features/Purchase/PurchaseReport/Components/PurchaseReportPage.razor new file mode 100644 index 0000000..0c6a330 --- /dev/null +++ b/Features/Purchase/PurchaseReport/Components/PurchaseReportPage.razor @@ -0,0 +1,8 @@ +@page "/purchase/purchase-report" +@using Indotalent.Features.Purchase.PurchaseReport.Components +@using MudBlazor + +<_PurchaseReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReport/Components/_PurchaseReportDataTable.razor b/Features/Purchase/PurchaseReport/Components/_PurchaseReportDataTable.razor new file mode 100644 index 0000000..e53d5af --- /dev/null +++ b/Features/Purchase/PurchaseReport/Components/_PurchaseReportDataTable.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseReport +@using Indotalent.Features.Purchase.PurchaseReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PurchaseReportService PurchaseReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Purchase Report + Detailed analysis of purchase order items and procurement costs. +
+
+ + / + Purchase + / + Purchase Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Vendor + + + Order No + + + Product Number + + + Product Name + + + Unit Price + + + Qty + + + Total + + + Order Date + + + + @context.VendorName + @context.PurchaseOrderNumber + @context.ProductNumber + @context.ProductName + @context.UnitPrice?.ToString("N2") + @context.Quantity?.ToString("N2") + @context.Total?.ToString("N2") + @context.OrderDate?.ToString("yyyy-MM-dd") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await PurchaseReportService.GetPurchaseReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PurchaseOrderNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PurchaseReports"); + var currentRow = 1; + + string[] headers = { "Vendor", "Order No", "Product Number", "Product Name", "Unit Price", "Qty", "Total", "Order Date" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.VendorName; + worksheet.Cell(currentRow, 2).Value = item.PurchaseOrderNumber; + worksheet.Cell(currentRow, 3).Value = item.ProductNumber; + worksheet.Cell(currentRow, 4).Value = item.ProductName; + worksheet.Cell(currentRow, 5).Value = item.UnitPrice; + worksheet.Cell(currentRow, 6).Value = item.Quantity; + worksheet.Cell(currentRow, 7).Value = item.Total; + worksheet.Cell(currentRow, 8).Value = item.OrderDate?.ToString("yyyy-MM-dd"); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Purchase_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReport/Cqrs/GetPurchaseReportListHandler.cs b/Features/Purchase/PurchaseReport/Cqrs/GetPurchaseReportListHandler.cs new file mode 100644 index 0000000..4a97efc --- /dev/null +++ b/Features/Purchase/PurchaseReport/Cqrs/GetPurchaseReportListHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReport.Cqrs; + +public record GetPurchaseReportListDto +{ + public string? Id { get; init; } + public string? PurchaseOrderId { get; init; } + public string? PurchaseOrderNumber { get; init; } + public string? VendorName { get; init; } + public string? ProductId { get; init; } + public string? ProductName { get; init; } + public string? ProductNumber { get; init; } + public string? Summary { get; init; } + public decimal? UnitPrice { get; init; } + public double? Quantity { get; init; } + public decimal? Total { get; init; } + public DateTime? OrderDate { get; init; } +} + +public record GetPurchaseReportListQuery() : IRequest>; + +public class GetPurchaseReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPurchaseReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetPurchaseReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.PurchaseOrderItem + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(x => x!.Vendor) + .Include(x => x.Product) + .OrderByDescending(x => x.PurchaseOrder!.OrderDate) + .Select(x => new GetPurchaseReportListDto + { + Id = x.Id, + PurchaseOrderId = x.PurchaseOrderId, + PurchaseOrderNumber = x.PurchaseOrder != null ? x.PurchaseOrder.AutoNumber : string.Empty, + VendorName = (x.PurchaseOrder != null && x.PurchaseOrder.Vendor != null) ? x.PurchaseOrder.Vendor.Name : string.Empty, + ProductId = x.ProductId, + ProductName = x.Product != null ? x.Product.Name : string.Empty, + ProductNumber = x.Product != null ? x.Product.AutoNumber : string.Empty, + Summary = x.Summary, + UnitPrice = x.UnitPrice, + Quantity = x.Quantity, + Total = x.Total, + OrderDate = x.PurchaseOrder != null ? x.PurchaseOrder.OrderDate : null + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReport/PurchaseReportEndpoint.cs b/Features/Purchase/PurchaseReport/PurchaseReportEndpoint.cs new file mode 100644 index 0000000..79f2835 --- /dev/null +++ b/Features/Purchase/PurchaseReport/PurchaseReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PurchaseReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PurchaseReport; + +public static class PurchaseReportEndpoint +{ + public static void MapPurchaseReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/purchase-report").WithTags("PurchaseReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReportListQuery()); + return result.ToApiResponse("Purchase report list retrieved successfully"); + }) + .WithName("GetPurchaseReportList"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReport/PurchaseReportService.cs b/Features/Purchase/PurchaseReport/PurchaseReportService.cs new file mode 100644 index 0000000..0520428 --- /dev/null +++ b/Features/Purchase/PurchaseReport/PurchaseReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PurchaseReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PurchaseReport; + +public class PurchaseReportService : BaseService +{ + private readonly RestClient _client; + + public PurchaseReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPurchaseReportListAsync() + { + var request = new RestRequest("api/purchase-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Root/App.razor b/Features/Root/App.razor new file mode 100644 index 0000000..5a36c50 --- /dev/null +++ b/Features/Root/App.razor @@ -0,0 +1,43 @@ +@using Indotalent.Features.Root +@using System.Security.Claims + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Features/Root/EmptyLayout.razor b/Features/Root/EmptyLayout.razor new file mode 100644 index 0000000..e4ab3c9 --- /dev/null +++ b/Features/Root/EmptyLayout.razor @@ -0,0 +1,2 @@ +@inherits LayoutComponentBase +@Body \ No newline at end of file diff --git a/Features/Root/Error/ErrorPage.razor b/Features/Root/Error/ErrorPage.razor new file mode 100644 index 0000000..31064ba --- /dev/null +++ b/Features/Root/Error/ErrorPage.razor @@ -0,0 +1,59 @@ +@page "/Error" +@layout MainLayout +@using System.Diagnostics +@using Microsoft.AspNetCore.Mvc +@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + +Error - Something Went Wrong + + + +
+ + + + An Error Occurred + + + Sorry, an error occurred while processing your request.
+ Our team has been notified and is working to resolve the issue. +
+ + @if (ShowRequestId) + { + + + Request ID: @RequestId + + + Please provide this ID when contacting support to help us track the issue. + + + } + + + Back to Home + + +
+ +
+ +@code { + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() + { + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } +} \ No newline at end of file diff --git a/Features/Root/Home/Components/DashboardPage.razor b/Features/Root/Home/Components/DashboardPage.razor new file mode 100644 index 0000000..548c8b7 --- /dev/null +++ b/Features/Root/Home/Components/DashboardPage.razor @@ -0,0 +1,671 @@ +@page "/home" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using Indotalent.Features.Root.Home.Cqrs +@using Indotalent.Features.Root.Home +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@inject HomeService HomeService +@inject IJSRuntime JS + +OMS Dashboard + +@if (!_dataReady) +{ +
+
+ + + +

Loading OMS Dashboard...

+
+
+} +else +{ +
+
+ + @* HEADER *@ +
+
+

OMS Dashboard

+
+
+ + @* ROW 1: 12-col KPI layout *@ +
+ @* Key Metrics Card *@ +
+
+
+
+

OMS Overview

+

Key Metrics

+
+
+ + + +
+
+
+
+

MTD Sales

+

@_data.FormattedMonthlySales

+
+
+

MTD Purchase

+

@_data.FormattedMonthlyPurchase

+
+
+

Growth Rate

+

@_data.OrderGrowthRate%

+
+
+

Total SO

+

@_data.FormattedTotalSalesOrders

+
+
+
+
+ AR: @_data.FormattedPendingPayments + FY @DateTime.Now.Year +
+
+ + @* 8 Small KPI Cards (row 1, cols 6-12) *@ +
+
+
+

MTD Sales

@_data.FormattedMonthlySales

+
+
+
+
+
+

MTD Purchase

@_data.FormattedMonthlyPurchase

+
+
+
+
+
+

Growth Rate

@_data.OrderGrowthRate%

+
+
+
+
+
+

Total SO

@_data.FormattedTotalSalesOrders

+
+
+
+
+
+
+
+

Avg Order Value

@_data.FormattedAvgOrderValue

+
+
+
+
+
+

Fulfillment Rate

@_data.SlaPercentage%

+
+
+
+
+
+

Pending Docs

@_data.ActiveDealsCount

+
+
+
+
+
+

Total Payable

@_data.FormattedTotalPayable

+
+
+
+
+
+ + @* Metrics Matrix *@ +
+
+

OMS Metrics Matrix

Key indicators across all categories

+
+
+ @foreach (var item in _data.DocGrid.Take(20)) + { + var mmColors = new[] { "mm-c1","mm-c2","mm-c3","mm-c4","mm-c5","mm-c6","mm-c7","mm-c8","mm-c9","mm-c10","mm-c11","mm-c12","mm-c13","mm-c14","mm-c15","mm-c16","mm-c17","mm-c18","mm-c19","mm-c20" }; + var idx = _data.DocGrid.IndexOf(item); + var colorClass = idx < mmColors.Length ? mmColors[idx] : "mm-c1"; +
+
+ +
+

@item.Label

@item.Value

+
+ } +
+
+ + @* ROW 2: Revenue Trend + Document Sources *@ +
+
+
+

Sales vs Purchase Trend

Monthly comparison

+
+ Sales + Purchase +
+
+
+
+
+

Document Sources

+

By category this period

+
+
+ @if (_data.OrderSources.Any()) + { + @foreach (var src in _data.OrderSources) + { +
+ @src.Source @src.Percentage% +
+ } + } + else + { +
SO 0%
+
PO 0%
+
INV 0%
+
BILL 0%
+ } +
+
+
+ + @* ROW 3: Pipeline Summary + Doc Stages + Order Activity *@ +
+
+

Pipeline Summary

+

As of @DateTime.Now.ToString("MMMM dd, yyyy")

+
+ @if (_data.PipelineSummary.Any()) + { + @foreach (var ps in _data.PipelineSummary) + { +
+
+ +
+

@ps.Label

@(ps.FormattedValue ?? ps.Value.ToString("N0"))

+
+ } + } +
+ Growth Rate@_data.OrderGrowthRate% + Orders@_data.FormattedTotalSalesOrders +
+
+
+
+

Doc Stages

+

Orders by status

+
+ @foreach (var ds in _data.DealStages) + { +
+
+ +
+

@ds.Stage

@ds.Count

+
+ } +
+
SLA: @_data.SlaPercentage%Active: @_data.ActiveDealsCount
+
+
+

Order Activity

+

Document volume

+
+
+ @foreach (var sa in _data.OrderActivities.Take(3)) + { +
+@sa.Count

@sa.Category

+ } +
+
+
+ + @* ROW 4: Recent Orders + OMS Activity *@ +
+
+
+

Recent Orders

Latest transactions

+
+
+ + + + @if (_data.RecentOrders.Any()) + { + @foreach (var order in _data.RecentOrders) + { + + + + + + + + } + } + else + { + + } + +
DateOrder#DescriptionValueStatus
@(order.Date?.ToString("MMM dd") ?? "-")@order.OrderNumber@order.Description@order.Value.ToString("N0")@order.Status
No recent orders available
+
+
+ Pending Docs: @_data.ActiveDealsCount + Total AR: @_data.FormattedPipeline +
+
+
+
+

OMS Activity

Real-time updates

+ Live +
+
+ @if (_data.ActivityFeeds.Any()) + { + @foreach (var af in _data.ActivityFeeds) + { +
+
@af.AvatarText
+

@af.Title

@af.Detail

@af.TimeAgo

+ @af.ChipLabel +
+ } + } + else + { +

No recent activity

+ } +
+
+
+ + @* ROW 5: Order Metrics + Top Customers + Quick Stats *@ +
+
+

Order Metrics

+

@DateTime.Now.Year Performance

+
+ @foreach (var cm in _data.OrderMetrics) + { +
+
+
+ +
+
+
+

@cm.Label

+ @cm.Current / @cm.Target +
+
+
+
+
+ } +
+
+ Target SLA: @_data.TargetGrowth% + @(_data.SlaPercentage >= 90 ? "On Track" : "Improving") +
+
+
+

Top Customers

+

By order volume

+
+ @if (_data.SalesLeaderboard.Any()) + { + var perfColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6" }; + @for (int i = 0; i < Math.Min(_data.SalesLeaderboard.Count, 4); i++) + { + var p = _data.SalesLeaderboard[i]; +
+
+ +
+

@p.Name

@p.Achievement.ToString("N0")

+ @p.WinRate.ToString("F0")% +
+ } + } + else + { +

No performance data available

+ } +
+
Top Orders: @_data.FormattedTotalSalesOrdersAvg Growth: @_data.OrderGrowthRate%
+
+
+

Quick Stats

+

System overview

+
+
+
+

Pending Docs

Drafts SO+PO

+
@_data.ActiveDealsCount
+
+
+
+

Total Customers

Active

+
@_data.FormattedTotalCustomers
+
+
+
+

Retention Rate

Customer loyalty

+
@_data.CustomerRetention%
+
+
+
+

Receivable

Total AR

+
@_data.FormattedTotalReceivable
+
+
+
+
+ + @* ROW 6: Vendor Overview + Pending Invoices *@ +
+
+
+

Vendor Overview

As of @DateTime.Now.ToString("MMM dd, yyyy") · Active vendors

+
+
+
+ + + + @if (_data.RecentVendors.Any()) + { + @foreach (var vend in _data.RecentVendors) + { + + + + + + + } + } + else + { + + } + +
VendorGroupAmountStage
@vend.LeadTitle@vend.Company@vend.Amount.ToString("N0")@vend.Stage
No vendors available
+
+
+
+ Total Vendors: @_data.TotalVendors.ToString("N0") + Products: @_data.TotalProducts.ToString("N0") +
+
+
+
+

Pending Invoices

Awaiting confirmation

+
+
+
+ + + + @if (_data.OverdueInvoices.Any()) + { + @foreach (var inv in _data.OverdueInvoices) + { + + + + + + + } + } + else + { + + } + +
InvoiceCustomerAmountStatus
@inv.Number@inv.Customer@inv.Amount.ToString("N0") + + @inv.Status + +
No pending invoices
+
+
+
+ Total AR: @_data.FormattedPendingPayments + Count: @_data.OverdueInvoices.Count +
+
+
+ +
+
+} + +@code { + private DashboardOmsResponse _data = new(); + private bool _isLoading = true; + private bool _chartsInitialized = false; + private bool _dataReady = false; + + protected override async Task OnInitializedAsync() + { + _isLoading = true; + try + { + var res = await HomeService.GetOmsDashboardStatsAsync(); + if (res?.IsSuccess == true && res.Value != null) + { + _data = res.Value; + _dataReady = true; + StateHasChanged(); + } + } + finally + { + _isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_dataReady && !_chartsInitialized) + { + _chartsInitialized = true; + await InitCharts(); + } + } + + private async Task InitCharts() + { + var ml = _data.RevenueTrend.Any() + ? _data.RevenueTrend.Select(x => x.Label ?? "").ToArray() + : new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun" }; + var ms = _data.RevenueTrend.Any() + ? _data.RevenueTrend.Select(x => x.SalesAmount).ToArray() + : new double[ml.Length]; + var mp = _data.RevenueTrend.Any() + ? _data.RevenueTrend.Select(x => x.PurchaseAmount).ToArray() + : new double[ml.Length]; + + var sl = _data.OrderSources.Any() + ? _data.OrderSources.Select(x => x.Source ?? "").ToArray() + : new[] { "SO", "PO", "INV", "BILL" }; + var sd = _data.OrderSources.Any() + ? _data.OrderSources.Select(x => x.Percentage).Cast().ToArray() + : new object[] { 10, 10, 10, 10 }; + var sc = _data.OrderSources.Any() + ? _data.OrderSources.Select(x => x.Color ?? "#06b6d4").ToArray() + : new[] { "#06b6d4", "#10b981", "#6366f1", "#dc2626" }; + + var al = _data.OrderActivities.Any() + ? _data.OrderActivities.Take(4).Select(x => x.Category ?? "").ToArray() + : new[] { "SO", "PO", "INV" }; + var ad = _data.OrderActivities.Any() + ? _data.OrderActivities.Take(4).Select(x => (double)x.Count).ToArray() + : new double[] { 0, 0, 0 }; + var ac = _data.OrderActivities.Any() + ? _data.OrderActivities.Take(4).Select(x => x.Color ?? "#6366f1").ToArray() + : new[] { "#10b981", "#e11d48", "#6366f1" }; + + await JS.InvokeVoidAsync("initOmsDashboardCharts", + ml, ms, mp, + sl, sd, sc, + al, ad, ac); + } +} + + + +@* Chart initialization script *@ + \ No newline at end of file diff --git a/Features/Root/Home/Cqrs/DashboardHandler.cs b/Features/Root/Home/Cqrs/DashboardHandler.cs new file mode 100644 index 0000000..835218d --- /dev/null +++ b/Features/Root/Home/Cqrs/DashboardHandler.cs @@ -0,0 +1,357 @@ +using Indotalent.Infrastructure.Database; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Root.Home.Cqrs; + +public class ChartDataPoint +{ + public string? Label { get; set; } + public double SalesAmount { get; set; } + public double PurchaseAmount { get; set; } +} + +public class ScmMetricDto +{ + public string? Label { get; set; } + public string? Value { get; set; } + public string? Color { get; set; } + public double Percentage { get; set; } +} + +public class DealStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } } +public class DocSourceDto { public string? Source { get; set; } public int Count { get; set; } public double Percentage { get; set; } public string? Color { get; set; } } +public class DocMetricDto { public string? Label { get; set; } public double Current { get; set; } public double Target { get; set; } public double PctValue { get; set; } public string? BarColor { get; set; } } +public class OrderActivityDto { public string? Category { get; set; } public int Count { get; set; } public string? Color { get; set; } } +public class RecentOrderDto { public string? OrderNumber { get; set; } public string? Description { get; set; } public decimal Value { get; set; } public string? Stage { get; set; } public string? Status { get; set; } public DateTime? Date { get; set; } } +public class ActivityFeedDto { public string? Title { get; set; } public string? Detail { get; set; } public string? TimeAgo { get; set; } public string? ChipLabel { get; set; } public string? ChipColor { get; set; } public string? AvatarBg { get; set; } public string? AvatarText { get; set; } } +public class PipelineSummaryDto { public string? Label { get; set; } public decimal Value { get; set; } public string? IconColorClass { get; set; } public string? FormattedValue { get; set; } } +public class SalesPerformanceDto { public string? Name { get; set; } public decimal Achievement { get; set; } public int Deals { get; set; } public double WinRate { get; set; } } +public class PendingInvoiceDto { public string? Number { get; set; } public string? Customer { get; set; } public decimal Amount { get; set; } public string? Status { get; set; } } +public class RecentLeadDto { public string? LeadTitle { get; set; } public string? Company { get; set; } public decimal Amount { get; set; } public string? Stage { get; set; } } + +public class DashboardOmsResponse +{ + public int TotalSalesOrders { get; set; } + public int TotalPurchaseOrders { get; set; } + public decimal MonthlySales { get; set; } + public decimal MonthlyPurchase { get; set; } + public int TotalCustomers { get; set; } + public int TotalVendors { get; set; } + public decimal PendingReceivable { get; set; } + public decimal TotalPayable { get; set; } + public decimal TotalReceivable { get; set; } + public int ActiveDealsCount { get; set; } + public decimal AvgOrderValue { get; set; } + public double OrderGrowthRate { get; set; } + public int PendingSODraft { get; set; } + public int PendingPODraft { get; set; } + public int TotalProducts { get; set; } + public int TotalInvoices { get; set; } + public int TotalBills { get; set; } + public double SlaPercentage { get; set; } + public double CustomerRetention { get; set; } + public double TargetGrowth { get; set; } + + public List DealStages { get; set; } = new(); + public List OrderSources { get; set; } = new(); + public List OrderMetrics { get; set; } = new(); + public List OrderActivities { get; set; } = new(); + public List RecentOrders { get; set; } = new(); + public List ActivityFeeds { get; set; } = new(); + public List PipelineSummary { get; set; } = new(); + public List SalesLeaderboard { get; set; } = new(); + public List OverdueInvoices { get; set; } = new(); + public List RecentVendors { get; set; } = new(); + public List DocGrid { get; set; } = new(); + public List RevenueTrend { get; set; } = new(); + + public string FormattedMonthlySales => FormatNumber(MonthlySales); + public string FormattedMonthlyPurchase => FormatNumber(MonthlyPurchase); + public string FormattedPipeline => FormatNumber(PendingReceivable); + public string FormattedPendingPayments => FormatNumber(PendingReceivable); + public string FormattedTotalPayable => FormatNumber(TotalPayable); + public string FormattedTotalReceivable => FormatNumber(TotalReceivable); + public string FormattedAvgOrderValue => FormatNumber(AvgOrderValue); + public string FormattedTotalSalesOrders => TotalSalesOrders.ToString("N0"); + public string FormattedTotalPurchaseOrders => TotalPurchaseOrders.ToString("N0"); + public string FormattedTotalCustomers => TotalCustomers.ToString("N0"); + + private string FormatNumber(decimal value) + { + if (value >= 1000000) + return (value / 1000000m).ToString("F1") + "M"; + if (value >= 1000) + return (value / 1000m).ToString("F1") + "K"; + return value.ToString("N0"); + } +} + +public record GetOmsDashboardQuery() : IRequest; + +public class DashboardHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DashboardHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetOmsDashboardQuery request, CancellationToken ct) + { + var today = DateTime.Today; + var monthStart = new DateTime(today.Year, today.Month, 1); + + // Monthly chart data + var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + var monthlyData = new List(); + + var soRaw = await _context.SalesOrder + .Where(x => x.OrderDate != null) + .Select(x => new { x.OrderDate, x.AfterTaxAmount }) + .ToListAsync(ct); + var poRaw = await _context.PurchaseOrder + .Where(x => x.OrderDate != null) + .Select(x => new { x.OrderDate, x.AfterTaxAmount }) + .ToListAsync(ct); + + var allDates = soRaw.Select(d => d.OrderDate!.Value) + .Concat(poRaw.Select(d => d.OrderDate!.Value)) + .Select(d => new { d.Year, d.Month }) + .Distinct() + .OrderByDescending(x => x.Year).ThenByDescending(x => x.Month) + .Take(6).Reverse() + .ToList(); + foreach (var ym in allDates) + { + var ms = new DateTime(ym.Year, ym.Month, 1); + var me = ms.AddMonths(1); + var salesAmount = soRaw.Where(x => x.OrderDate >= ms && x.OrderDate < me).Sum(x => (double)(x.AfterTaxAmount ?? 0)); + var purchaseAmount = poRaw.Where(x => x.OrderDate >= ms && x.OrderDate < me).Sum(x => (double)(x.AfterTaxAmount ?? 0)); + monthlyData.Add(new ChartDataPoint + { + Label = monthlyLabels[ym.Month - 1], + SalesAmount = Math.Round(salesAmount / 1000.0, 1), + PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1) + }); + } + + // Sequential queries (DbContext is not thread-safe) + var totalSO = await _context.SalesOrder.CountAsync(ct); + var totalPO = await _context.PurchaseOrder.CountAsync(ct); + var totalCust = await _context.Customer.CountAsync(ct); + var totalVend = await _context.Vendor.CountAsync(ct); + var totalInv = await _context.Invoice.CountAsync(ct); + var totalBill = await _context.Bill.CountAsync(ct); + var totalPR = await _context.PaymentReceive.CountAsync(ct); + var totalPD = await _context.PaymentDisburse.CountAsync(ct); + var totalProd = await _context.Set().CountAsync(ct); + var pendingSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Draft, ct); + var pendingPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Draft, ct); + var salesMTD = await _context.SalesOrder.Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct); + var purchaseMTD = await _context.PurchaseOrder.Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct); + var pendingAR = await _context.Invoice.Where(x => x.InvoiceStatus != InvoiceStatus.Confirmed).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct); + var totalReceivable = await _context.Invoice.Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct); + var totalPayable = await _context.Bill.Where(x => x.BillStatus == BillStatus.Confirmed).SumAsync(x => x.PurchaseOrder!.AfterTaxAmount ?? 0, ct); + + // Doc stages (like deal stages) + var confirmedSO = await _context.SalesOrder.CountAsync(x => x.OrderStatus == SalesOrderStatus.Confirmed, ct); + var confirmedPO = await _context.PurchaseOrder.CountAsync(x => x.OrderStatus == PurchaseOrderStatus.Confirmed, ct); + var dealStages = new List + { + new() { Stage = "Draft SO", Count = pendingSO, Color = "#8b5cf6" }, + new() { Stage = "Confirmed SO", Count = confirmedSO, Color = "#f59e0b" }, + new() { Stage = "Draft PO", Count = pendingPO, Color = "#e11d48" }, + new() { Stage = "Confirmed PO", Count = confirmedPO, Color = "#6366f1" } + }; + + // Doc sources (order types) + var totalDocs = totalSO + totalPO + totalInv + totalBill; + var orderSources = new List + { + new() { Source = "Sales Orders", Count = totalSO, Percentage = totalDocs > 0 ? Math.Round((double)totalSO / totalDocs * 100, 1) : 0, Color = "#2563eb" }, + new() { Source = "Purch Orders", Count = totalPO, Percentage = totalDocs > 0 ? Math.Round((double)totalPO / totalDocs * 100, 1) : 0, Color = "#f97316" }, + new() { Source = "Invoices", Count = totalInv, Percentage = totalDocs > 0 ? Math.Round((double)totalInv / totalDocs * 100, 1) : 0, Color = "#ec4899" }, + new() { Source = "Bills", Count = totalBill, Percentage = totalDocs > 0 ? Math.Round((double)totalBill / totalDocs * 100, 1) : 0, Color = "#8b5cf6" }, + new() { Source = "Payments", Count = totalPR + totalPD, Percentage = totalDocs > 0 ? Math.Round((double)(totalPR + totalPD) / totalDocs * 100, 1) : 0, Color = "#06b6d4" } + }; + + // Order metrics (like conversion metrics) + var orderMetrics = new List + { + new() { Label = "SO Draft→Confirm", Current = confirmedSO, Target = totalSO, PctValue = totalSO > 0 ? Math.Round((double)confirmedSO / totalSO * 100, 1) : 0, BarColor = "#2563eb" }, + new() { Label = "SO→Invoice", Current = totalInv, Target = totalSO, PctValue = totalSO > 0 ? Math.Round((double)totalInv / totalSO * 100, 1) : 0, BarColor = "#f97316" }, + new() { Label = "PO Draft→Confirm", Current = confirmedPO, Target = totalPO, PctValue = totalPO > 0 ? Math.Round((double)confirmedPO / totalPO * 100, 1) : 0, BarColor = "#ec4899" }, + new() { Label = "Overall Fulfillment", Current = totalInv + totalBill, Target = totalSO + totalPO, PctValue = (totalSO + totalPO) > 0 ? Math.Round((double)(totalInv + totalBill) / (totalSO + totalPO) * 100, 1) : 0, BarColor = "#10b981" } + }; + + // Order activities + var orderActivities = new List + { + new() { Category = "Sales Orders", Count = totalSO, Color = "#10b981" }, + new() { Category = "Purch Orders", Count = totalPO, Color = "#e11d48" }, + new() { Category = "Invoices", Count = totalInv, Color = "#e11d48" }, + new() { Category = "Total Docs", Count = totalDocs, Color = "#6366f1" } + }; + + // Recent orders + var recentOrders = await _context.SalesOrder + .AsNoTracking() + .OrderByDescending(x => x.OrderDate) + .Take(4) + .Select(x => new RecentOrderDto + { + OrderNumber = x.AutoNumber, + Description = x.Description, + Value = x.AfterTaxAmount ?? 0, + Stage = x.OrderStatus.ToString(), + Status = x.OrderStatus == SalesOrderStatus.Confirmed ? "Posted" : x.OrderStatus == SalesOrderStatus.Cancelled ? "Cancelled" : "Pending", + Date = x.OrderDate + }).ToListAsync(ct); + + // Pipeline summary + var pipelineSummaryList = new List + { + new() { Label = "MTD Sales", Value = salesMTD, FormattedValue = FormatDecimal(salesMTD), IconColorClass = "gc1" }, + new() { Label = "MTD Purchase", Value = purchaseMTD, FormattedValue = FormatDecimal(purchaseMTD), IconColorClass = "gc10" }, + new() { Label = "Avg Order Value", Value = totalSO > 0 ? salesMTD / totalSO : 0, IconColorClass = "gc2" } + }; + + // Activity feed from recent SO + var recentSOs = await _context.SalesOrder + .AsNoTracking() + .OrderByDescending(x => x.OrderDate).Take(4) + .Select(x => new { x.AutoNumber, x.OrderDate, x.Customer!.Name }) + .ToListAsync(ct); + + var activityFeed = recentSOs.Select((so, i) => new ActivityFeedDto + { + Title = $"Sales Order #{so.AutoNumber}", + Detail = $"Customer: {so.Name}", + TimeAgo = so.OrderDate.HasValue ? $"{(int)(DateTime.Now - so.OrderDate.Value).TotalDays}d ago" : "", + ChipLabel = "SO Created", + ChipColor = i % 2 == 0 ? "chip-green" : "chip-indigo", + AvatarBg = i switch { 0 => "#10b981", 1 => "#2563eb", 2 => "#6366f1", _ => "#f97316" }, + AvatarText = "SO" + }).ToList(); + + // Customer performance (top customers by count) + var topCustRaw = await _context.SalesOrder + .AsNoTracking() + .Where(x => x.Customer != null) + .GroupBy(x => x.Customer!.Name) + .OrderByDescending(g => g.Count()) + .Take(4) + .Select(g => new { Name = g.Key ?? "N/A", Cnt = g.Count(), Val = g.Sum(x => x.AfterTaxAmount ?? 0) }) + .ToListAsync(ct); + + var salesLeaderboard = topCustRaw.Select(c => new SalesPerformanceDto + { + Name = c.Name, + Achievement = c.Val, + Deals = c.Cnt, + WinRate = totalSO > 0 ? Math.Round((double)c.Cnt / totalSO * 100, 1) : 0 + }).ToList(); + + // Overdue invoices + var overdueInvoices = await _context.Invoice + .AsNoTracking() + .Where(x => x.InvoiceStatus != InvoiceStatus.Confirmed && x.InvoiceStatus != InvoiceStatus.Cancelled) + .OrderByDescending(x => x.CreatedAt).Take(5) + .Select(x => new PendingInvoiceDto + { + Number = x.AutoNumber, + Customer = x.SalesOrder!.Customer!.Name, + Amount = x.SalesOrder!.AfterTaxAmount ?? 0, + Status = x.InvoiceStatus.ToString() + }).ToListAsync(ct); + + // Recent vendors (like recent leads) + var recentVendors = await _context.Vendor + .AsNoTracking() + .OrderBy(x => x.Name) + .Take(5) + .Select(x => new RecentLeadDto + { + LeadTitle = x.Name, + Company = x.VendorGroup != null ? x.VendorGroup.Name : "General", + Amount = 0, + Stage = "Active" + }).ToListAsync(ct); + + // Compute derived values + var avgOrderValue = totalSO > 0 ? salesMTD / totalSO : 0; + var orderGrowthRate = totalSO > 0 ? Math.Round((double)totalInv / totalSO * 100, 1) : 42; + var slaPercentage = totalSO > 0 ? Math.Round((double)confirmedSO / totalSO * 100, 1) : 95; + var customerRetention = totalCust > 0 ? Math.Round((double)(totalCust - pendingSO) / totalCust * 100, 1) : 92; + + // Doc grid for metrics matrix + var docGrid = new List + { + new() { Label = "Sales Orders", Value = totalSO.ToString(), Color = "#2563eb" }, + new() { Label = "Purch Orders", Value = totalPO.ToString(), Color = "#10b981" }, + new() { Label = "Invoices", Value = totalInv.ToString(), Color = "#ec4899" }, + new() { Label = "Bills", Value = totalBill.ToString(), Color = "#8b5cf6" }, + new() { Label = "Pay Receive", Value = totalPR.ToString(), Color = "#f59e0b" }, + new() { Label = "Pay Disburse", Value = totalPD.ToString(), Color = "#06b6d4" }, + new() { Label = "Customers", Value = totalCust.ToString("N0"), Color = "#14b8a6" }, + new() { Label = "Vendors", Value = totalVend.ToString("N0"), Color = "#f97316" }, + new() { Label = "Products", Value = totalProd.ToString("N0"), Color = "#059669" }, + new() { Label = "Draft SO", Value = pendingSO.ToString(), Color = "#e11d48" }, + new() { Label = "Draft PO", Value = pendingPO.ToString(), Color = "#db2777" }, + new() { Label = "Pending AR", Value = FormatDecimal(pendingAR), Color = "#d97706" }, + new() { Label = "Pending AP", Value = FormatDecimal(totalPayable), Color = "#0284c7" }, + new() { Label = "Avg SO Value", Value = FormatDecimal(totalSO > 0 ? salesMTD / totalSO : 0), Color = "#65a30d" }, + new() { Label = "Avg PO Value", Value = FormatDecimal(totalPO > 0 ? purchaseMTD / totalPO : 0), Color = "#9333ea" }, + new() { Label = "SLA Target", Value = "95%", Color = "#ea580c" }, + new() { Label = "Growth Rate", Value = $"{orderGrowthRate:F1}%", Color = "#0d9488" }, + new() { Label = "Retention", Value = $"{customerRetention:F1}%", Color = "#7c3aed" }, + new() { Label = "AR Balance", Value = FormatDecimal(totalReceivable), Color = "#0891b2" }, + new() { Label = "AP Balance", Value = FormatDecimal(totalPayable), Color = "#db2777" } + }; + + return new DashboardOmsResponse + { + TotalSalesOrders = totalSO, + TotalPurchaseOrders = totalPO, + MonthlySales = salesMTD, + MonthlyPurchase = purchaseMTD, + TotalCustomers = totalCust, + TotalVendors = totalVend, + PendingReceivable = pendingAR, + TotalPayable = totalPayable, + TotalReceivable = totalReceivable, + ActiveDealsCount = pendingSO + pendingPO, + AvgOrderValue = avgOrderValue, + OrderGrowthRate = orderGrowthRate, + PendingSODraft = pendingSO, + PendingPODraft = pendingPO, + TotalProducts = totalProd, + TotalInvoices = totalInv, + TotalBills = totalBill, + SlaPercentage = slaPercentage, + CustomerRetention = customerRetention, + TargetGrowth = 5.0, + DealStages = dealStages, + OrderSources = orderSources, + OrderMetrics = orderMetrics, + OrderActivities = orderActivities, + RecentOrders = recentOrders, + ActivityFeeds = activityFeed, + PipelineSummary = pipelineSummaryList, + SalesLeaderboard = salesLeaderboard, + OverdueInvoices = overdueInvoices, + RecentVendors = recentVendors, + DocGrid = docGrid, + RevenueTrend = monthlyData + }; + } + + private static string FormatDecimal(decimal value) + { + if (value >= 1000000) + return (value / 1000000m).ToString("F1") + "M"; + if (value >= 1000) + return (value / 1000m).ToString("F1") + "K"; + return value.ToString("N0"); + } +} \ No newline at end of file diff --git a/Features/Root/Home/HomeEndpoint.cs b/Features/Root/Home/HomeEndpoint.cs new file mode 100644 index 0000000..df4ce1f --- /dev/null +++ b/Features/Root/Home/HomeEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Root.Home.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Root.Home; + +public static class HomeEndpoint +{ + public static void MapHomeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/dashboard").WithTags("Dashboard") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/oms-stats", async (IMediator mediator) => + { + var result = await mediator.Send(new GetOmsDashboardQuery()); + return result.ToApiResponse("Dashboard data retrieved successfully"); + }) + .WithName("GetOmsDashboardStats"); + } +} \ No newline at end of file diff --git a/Features/Root/Home/HomeService.cs b/Features/Root/Home/HomeService.cs new file mode 100644 index 0000000..bfd3bfe --- /dev/null +++ b/Features/Root/Home/HomeService.cs @@ -0,0 +1,28 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Root.Home.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Root.Home; + +public class HomeService : BaseService +{ + private readonly RestClient _client; + + public HomeService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetOmsDashboardStatsAsync() + { + var request = new RestRequest("api/dashboard/oms-stats", Method.Get); + request.AddHeader("Accept", "application/json"); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Root/MainLayout.razor b/Features/Root/MainLayout.razor new file mode 100644 index 0000000..e2eb9c8 --- /dev/null +++ b/Features/Root/MainLayout.razor @@ -0,0 +1,221 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Infrastructure.Authentication +@using Indotalent.Infrastructure.Authorization.Identity +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Components.Authorization +@inherits LayoutComponentBase +@inject NavigationManager NavigationManager +@inject ICurrentUserService CurrentUserService + + + + + + + + + + + + Profile + Logout + + + + + + + + +
+ + @if (_drawerOpen) + { + @GlobalConsts.AppInitial + } +
+
+ + + + + @(_drawerOpen ? "Home" : "") + @(_drawerOpen ? "Profile" : "") + @(_drawerOpen ? "Third Party" : "") + @(_drawerOpen ? "Sales" : "") + @(_drawerOpen ? "Purchase" : "") + @(_drawerOpen ? "Inventory" : "") + @(_drawerOpen ? "Utilities" : "") + + + + + + @(_drawerOpen ? "System Logs" : "") + @(_drawerOpen ? "App Settings" : "") + @(_drawerOpen ? "System Settings" : "") + + + + + +
+
+ + + + @Body + + +
+ + + +@code { + private bool _drawerOpen = true; + + private void DrawerToggle() => _drawerOpen = !_drawerOpen; + private void GoToProfile() => NavigationManager.NavigateTo("/profile"); + private void Logout() => NavigationManager.NavigateTo("/account/logout"); +} + + + + + + \ No newline at end of file diff --git a/Features/Root/MainLayout.razor.css b/Features/Root/MainLayout.razor.css new file mode 100644 index 0000000..10d6b84 --- /dev/null +++ b/Features/Root/MainLayout.razor.css @@ -0,0 +1,22 @@ + + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/Features/Root/NotFound/NotFoundPage.razor b/Features/Root/NotFound/NotFoundPage.razor new file mode 100644 index 0000000..a7458f0 --- /dev/null +++ b/Features/Root/NotFound/NotFoundPage.razor @@ -0,0 +1,33 @@ +@page "/not-found" +@using Microsoft.AspNetCore.Mvc +@layout MainLayout +@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + + + +
+ + + + Page Not Found (404) + + + Sorry, the page you are looking for is unavailable or has been moved. +
+ Please double-check the URL or return to the home page. +
+ + + Back to Home + + +
+ +
\ No newline at end of file diff --git a/Features/Root/Pages/LandingPage.cshtml b/Features/Root/Pages/LandingPage.cshtml new file mode 100644 index 0000000..e9c2447 --- /dev/null +++ b/Features/Root/Pages/LandingPage.cshtml @@ -0,0 +1,378 @@ +@page "/" + + + + + + + OMS - Blazor Enterprise OMS Source Code + + + + + + + + + + + + + + + +
+
+
+
+
+ + Production-Ready Blazor OMS Source Code +
+

+ Your OMS,
+ Deployed in Minutes +

+

+ A complete, end-to-end Order Management System source code built with ASP.NET Core Blazor .NET 10 and MudBlazor. + Stop burning AI tokens building from scratch — get a guaranteed working application with beautiful, modern design. +

+ +
+ Why choose us + End-to-End Working + Beautiful MudBlazor UI + Save Weeks of Development +
+
+
+
+ + +
+
+
+ THE PROBLEM +

Building a Full App Is Hard — Even with AI

+

AI can generate code, but stitching together an end-to-end application that actually works is a different story. We solved that for you.

+
+
+
+
+ +
+

AI Alone Isn't Enough

+

Sure, AI can help write code snippets. But building a fully integrated OMS — with authentication, database, business logic, and a polished UI — takes countless iterations, debugging, and wasted tokens.

+
+
+
+ +
+

Guaranteed to Work

+

Every feature in this OMS has been built, tested, and verified to run end-to-end. No half-baked modules, no broken pipelines — just a solid, production-grade application you can trust.

+
+
+
+ +
+

Beautiful by Default

+

Powered by MudBlazor 9, the UI is modern, responsive, and professional right out of the box. No need to fight with CSS or design tokens — it looks stunning from day one.

+
+
+
+ +
+

Single Project Monolith

+

No microservice complexity, no juggling between 20 projects. One solution, one deployment unit — clean Vertical Slice Architecture that scales when you need it to.

+
+
+
+ +
+

Full Source Code Included

+

You own the code. Customize, extend, and modify every line to fit your business. No black boxes, no vendor lock-in — just clean, well-structured C# code with LINQ and MediatR.

+
+
+
+ +
+

.NET 10 + Blazor Server

+

Built on the latest Microsoft stack — ASP.NET Core 10 with Blazor Server, Entity Framework Core, and MS SQL. A real-world demonstration of enterprise-grade .NET in action.

+
+
+
+
+ + +
+
+
+ BUSINESS MODULES +

Everything You Need to Run Your Business

+

Seamless procurement to bill, streamlined sales to invoice — every module is built, integrated, and ready to use.

+
+
+
+
+ +
+

Profile

+

Personal info, password management, avatar upload, and session tracking — a complete user profile system built in.

+
+
+
+ +
+

Third Party

+

Customer & vendor management with groups, categories, and contacts. Complete relationship tracking for both sides of your business.

+
+
+
+ +
+

Sales

+

Sales orders, invoices, payments. Comprehensive sales, invoice & payment reporting for full revenue visibility across the entire sales cycle.

+
+
+
+ +
+

Purchase

+

Purchase orders, bills, vendor payments. Purchase, bill & payment reports for complete spend management and procurement tracking.

+
+
+
+ +
+

Inventory

+

Unit of measure, product groups, product catalog, warehouse management. Complete stock control and catalog organization.

+
+
+
+ +
+

Utilities

+

Booking group & resource management, scheduler, program resource & manager, plus a built-in to-do list to keep your team organized.

+
+
+
+ +
+

System Logs

+

Database log and file log. Full audit trail and diagnostic tracking for every operation in the system.

+
+
+
+ +
+

App & System Settings

+

User management, currencies, auto numbering, and full inspection mode for appsettings.json — complete control over your application environment.

+
+
+
+
+ + +
+
+
+ TECHNOLOGY STACK +

Powered by Modern .NET 10

+

Real-world demonstration of ASP.NET Core Blazor's robustness, built with the latest tools from Microsoft.

+
+
+
ASP.NET Core 10
Framework
+
Blazor Server
Web UI
+
C# / LINQ
Language
+
.NET 10
Runtime
+
EF Core
ORM
+
MS SQL
Database
+
MudBlazor 9
UI Components
+
MediatR / CQRS
Pattern
+
VSA
Architecture
+
Visual Studio 2026
IDE
+
Monolithic
Single Project
+
+
+
+ + +
+
+
+ SEE IT IN ACTION +

Try the Live Demo

+

Don't take our word for it. Explore every feature in the live demo — no installation required.

+
+
+
+
+
+ +
+

Demo Credentials

+

Use these credentials to sign in and explore the full application:

+
+
+ +
+ Email + admin@root.com +
+
+ Password + 123456 +
+
+ +
+
+
+
+ + +
+
+

Stop Building from Scratch

+

Get a complete, tested, and beautiful OMS source code. Focus on growing your business — we've handled the heavy lifting.

+ +
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/Features/Root/ReconnectModal.razor b/Features/Root/ReconnectModal.razor new file mode 100644 index 0000000..7611248 --- /dev/null +++ b/Features/Root/ReconnectModal.razor @@ -0,0 +1,258 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
+ + + + + diff --git a/Features/Root/Routes.razor b/Features/Root/Routes.razor new file mode 100644 index 0000000..5a524f4 --- /dev/null +++ b/Features/Root/Routes.razor @@ -0,0 +1,25 @@ +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Features.Account.AccessDenied +@using Indotalent.Features.Root.NotFound +@using Microsoft.AspNetCore.Components.Authorization + + + + + + + + + + +
+ + Memverifikasi identitas... +
+
+
+ +
+
+
+ diff --git a/Features/Root/Shared/_DeleteConfirmation.razor b/Features/Root/Shared/_DeleteConfirmation.razor new file mode 100644 index 0000000..538f152 --- /dev/null +++ b/Features/Root/Shared/_DeleteConfirmation.razor @@ -0,0 +1,58 @@ +@using MudBlazor + + + +
+
+ + Confirm Delete +
+
+
+ +
+ Are you sure you want to delete @ContentText? + This action cannot be undone. +
+
+ +
+ Cancel + + @if (_isDeleting) + { + + Deleting... + } + else + { + Yes, Delete It + } + +
+
+
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string ContentText { get; set; } = string.Empty; + + private bool _isDeleting = false; + + private async Task HandleSubmit() + { + _isDeleting = true; + StateHasChanged(); + await Task.Delay(1000); + MudDialog.Close(DialogResult.Ok(true)); + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Root/SsoFirebase.razor b/Features/Root/SsoFirebase.razor new file mode 100644 index 0000000..949ad68 --- /dev/null +++ b/Features/Root/SsoFirebase.razor @@ -0,0 +1,41 @@ +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.Extensions.Options +@inject IOptions IdentityOptions + +@if (IdentityOptions.Value.SsoFirebase.IsUsed) +{ + + + +} \ No newline at end of file diff --git a/Features/Root/SsoKeycloak.razor b/Features/Root/SsoKeycloak.razor new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/Features/Root/SsoKeycloak.razor @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Features/Root/_Imports.razor b/Features/Root/_Imports.razor new file mode 100644 index 0000000..09f7c74 --- /dev/null +++ b/Features/Root/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Indotalent +@using Indotalent.Features.Root +@using MudBlazor + +@inject NavigationManager NavigationManager \ No newline at end of file diff --git a/Features/Sales/Invoice/Components/InvoicePage.razor b/Features/Sales/Invoice/Components/InvoicePage.razor new file mode 100644 index 0000000..6ff661c --- /dev/null +++ b/Features/Sales/Invoice/Components/InvoicePage.razor @@ -0,0 +1,51 @@ +@page "/sales/invoice" +@using Indotalent.Features.Sales.Invoice.Cqrs +@using Indotalent.Features.Sales.Invoice.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_InvoiceCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_InvoiceUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_InvoiceDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateInvoiceRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateInvoiceRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Components/_InvoiceCreateForm.razor b/Features/Sales/Invoice/Components/_InvoiceCreateForm.razor new file mode 100644 index 0000000..f1420c6 --- /dev/null +++ b/Features/Sales/Invoice/Components/_InvoiceCreateForm.razor @@ -0,0 +1,181 @@ +@using Indotalent.Features.Sales.Invoice.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject InvoiceService InvoiceService +@inject ISnackbar Snackbar + + +
+ +
+ Add Invoice + Create a new billing invoice from Sales Order. +
+
+
+ + + + + Basic Information + + + Invoice Date + + + + + Sales Order Reference + + @foreach (var item in _lookup.SalesOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_InvoiceSalesOrderItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Payment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Amount + @_afterTax.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Invoice + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateInvoiceRequest _model = new() { InvoiceDate = DateTime.Today, InvoiceStatus = Indotalent.Data.Enums.InvoiceStatus.Draft }; + private InvoiceLookupResponse _lookup = new(); + private List _items = new(); + private decimal _beforeTax = 0; + private decimal _taxAmount = 0; + private decimal _afterTax = 0; + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await InvoiceService.GetInvoiceLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnSalesOrderChanged(string soId) + { + _model.SalesOrderId = soId; + if (string.IsNullOrEmpty(soId)) + { + _items.Clear(); + _beforeTax = 0; + _taxAmount = 0; + _afterTax = 0; + return; + } + + var res = await InvoiceService.GetSalesOrderDetailForInvoiceAsync(soId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _beforeTax = res.Value.BeforeTaxAmount ?? 0; + _taxAmount = res.Value.TaxAmount ?? 0; + _afterTax = res.Value.AfterTaxAmount ?? 0; + Snackbar.Add("Sales Order reference selected. Payment summary and items have been loaded.", Severity.Info); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await InvoiceService.CreateInvoiceAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Components/_InvoiceDataTable.razor b/Features/Sales/Invoice/Components/_InvoiceDataTable.razor new file mode 100644 index 0000000..676cdd6 --- /dev/null +++ b/Features/Sales/Invoice/Components/_InvoiceDataTable.razor @@ -0,0 +1,380 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.Invoice +@using Indotalent.Features.Sales.Invoice.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject InvoiceService InvoiceService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Invoice + Manage customer billing based on Sales Orders. +
+
+ + / + Sales + / + Invoice +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Invoice + + } +
+
+ + + + + + Invoice No + + + Date + + + SO Ref + + + Customer + + + Status + + + Total Amount + + + + + + + @context.AutoNumber + @context.InvoiceDate?.ToString("yyyy-MM-dd") + @context.SalesOrderAutoNumber + @context.CustomerName + + @context.InvoiceStatus.ToString().ToUpper() + + @context.AfterTaxAmount?.ToString("N2") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetInvoiceListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await InvoiceService.GetInvoiceListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SalesOrderAutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Invoices"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Invoice Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "SO Reference"; + worksheet.Cell(currentRow, 4).Value = "Customer"; + worksheet.Cell(currentRow, 5).Value = "Status"; + worksheet.Cell(currentRow, 6).Value = "Total Amount"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.InvoiceDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.SalesOrderAutoNumber; + worksheet.Cell(currentRow, 4).Value = item.CustomerName; + worksheet.Cell(currentRow, 5).Value = item.InvoiceStatus.ToString(); + worksheet.Cell(currentRow, 6).Value = item.AfterTaxAmount; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Invoice_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await InvoiceService.GetInvoiceByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateInvoiceRequest + { + Id = detail.Id, + InvoiceDate = detail.InvoiceDate, + InvoiceStatus = detail.InvoiceStatus, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + SalesOrderId = detail.SalesOrderId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await InvoiceService.DeleteInvoiceByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Components/_InvoiceSalesOrderItemDataTable.razor b/Features/Sales/Invoice/Components/_InvoiceSalesOrderItemDataTable.razor new file mode 100644 index 0000000..427b6b1 --- /dev/null +++ b/Features/Sales/Invoice/Components/_InvoiceSalesOrderItemDataTable.razor @@ -0,0 +1,35 @@ +@using Indotalent.Features.Sales.Invoice.Cqrs +@using MudBlazor + + +
+ Referenced Sales Order Items +
+ + + + Product + Summary + Unit Price + Quantity + Total + + + @context.ProductName + @context.Summary + @context.UnitPrice?.ToString("N2") + @context.Quantity + @context.Total?.ToString("N2") + + +
+ + + + +@code { + [Parameter] public List Items { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = true; +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Components/_InvoiceUpdateForm.razor b/Features/Sales/Invoice/Components/_InvoiceUpdateForm.razor new file mode 100644 index 0000000..4624b40 --- /dev/null +++ b/Features/Sales/Invoice/Components/_InvoiceUpdateForm.razor @@ -0,0 +1,265 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.Invoice.Cqrs +@using Indotalent.Features.Sales.Invoice.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject InvoiceService InvoiceService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Invoice + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Invoice Date + + + + + Sales Order Reference + + @foreach (var item in _lookup.SalesOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_InvoiceSalesOrderItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Payment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Amount + @_afterTax.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateInvoiceRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateInvoiceRequest _model = new(); + private List _items = new(); + private InvoiceLookupResponse _lookup = new(); + private decimal _beforeTax = 0; + private decimal _taxAmount = 0; + private decimal _afterTax = 0; + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await InvoiceService.GetInvoiceLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnSalesOrderChanged(string soId) + { + if (ReadOnly || _model.SalesOrderId == soId) return; + _model.SalesOrderId = soId; + await RefreshItems(); + Snackbar.Add("Sales Order reference changed. Payment summary and items have been updated.", Severity.Info); + } + + private async Task RefreshItems() + { + if (string.IsNullOrEmpty(_model.SalesOrderId)) return; + + try + { + var response = await InvoiceService.GetSalesOrderDetailForInvoiceAsync(_model.SalesOrderId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + _beforeTax = response.Value.BeforeTaxAmount ?? 0; + _taxAmount = response.Value.TaxAmount ?? 0; + _afterTax = response.Value.AfterTaxAmount ?? 0; + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + await Task.Delay(1000); + + var response = await InvoiceService.GetInvoiceByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = InvoicePdfGenerator.Generate(response.Value); + var fileName = $"INV_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("PDF generated successfully.", Severity.Success); + } + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await InvoiceService.UpdateInvoiceAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/CreateInvoiceHandler.cs b/Features/Sales/Invoice/Cqrs/CreateInvoiceHandler.cs new file mode 100644 index 0000000..c051740 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/CreateInvoiceHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class CreateInvoiceRequest +{ + public DateTime? InvoiceDate { get; set; } + public Data.Enums.InvoiceStatus InvoiceStatus { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } +} + +public class CreateInvoiceResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateInvoiceCommand(CreateInvoiceRequest Data) : IRequest; + +public class CreateInvoiceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateInvoiceHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateInvoiceCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Invoice); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"INV/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Invoice + { + AutoNumber = autoNo, + InvoiceDate = request.Data.InvoiceDate, + InvoiceStatus = request.Data.InvoiceStatus, + Description = request.Data.Description, + SalesOrderId = request.Data.SalesOrderId + }; + + _context.Invoice.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateInvoiceResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/CreateInvoiceValidator.cs b/Features/Sales/Invoice/Cqrs/CreateInvoiceValidator.cs new file mode 100644 index 0000000..5f62bff --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/CreateInvoiceValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class CreateInvoiceValidator : AbstractValidator +{ + public CreateInvoiceValidator() + { + RuleFor(x => x.InvoiceDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.SalesOrderId).NotEmpty().WithMessage("Sales Order is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/DeleteInvoiceByIdHandler.cs b/Features/Sales/Invoice/Cqrs/DeleteInvoiceByIdHandler.cs new file mode 100644 index 0000000..eacd9d3 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/DeleteInvoiceByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public record DeleteInvoiceByIdCommand(string Id) : IRequest; + +public class DeleteInvoiceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteInvoiceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteInvoiceByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Invoice + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.Invoice.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/GetInvoiceByIdHandler.cs b/Features/Sales/Invoice/Cqrs/GetInvoiceByIdHandler.cs new file mode 100644 index 0000000..d7f6e88 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/GetInvoiceByIdHandler.cs @@ -0,0 +1,108 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class InvoiceSalesOrderItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } + public decimal? Total { get; set; } +} + +public class GetInvoiceByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public Data.Enums.InvoiceStatus InvoiceStatus { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } + public string? SalesOrderAutoNumber { get; set; } + public string? CustomerName { get; set; } + public string? CustomerStreet { get; set; } + public string? CustomerCity { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CompanyState { get; set; } + public string? CompanyZipCode { get; set; } + public string? CompanyPhone { get; set; } + public string? CurrencySymbol { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetInvoiceByIdQuery(string Id) : IRequest; + +public class GetInvoiceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetInvoiceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetInvoiceByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.Invoice + .AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(x => x!.Customer) + .Include(x => x.SalesOrder) + .ThenInclude(x => x!.SalesOrderItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetInvoiceByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + InvoiceDate = x.InvoiceDate, + InvoiceStatus = x.InvoiceStatus, + Description = x.Description, + SalesOrderId = x.SalesOrderId, + SalesOrderAutoNumber = x.SalesOrder!.AutoNumber, + CustomerName = x.SalesOrder!.Customer!.Name, + CustomerStreet = x.SalesOrder!.Customer!.Street, + CustomerCity = x.SalesOrder!.Customer!.City, + CompanyName = company != null ? company.Name : string.Empty, + CompanyStreet = company != null ? company.StreetAddress : string.Empty, + CompanyCity = company != null ? company.City : string.Empty, + CompanyState = company != null ? company.StateProvince : string.Empty, + CompanyZipCode = company != null ? company.ZipCode : string.Empty, + CompanyPhone = company != null ? company.Phone : string.Empty, + CurrencySymbol = company != null && company.Currency != null ? company.Currency.Symbol : "IDR", + BeforeTaxAmount = x.SalesOrder!.BeforeTaxAmount, + TaxAmount = x.SalesOrder!.TaxAmount, + AfterTaxAmount = x.SalesOrder!.AfterTaxAmount, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Items = x.SalesOrder!.SalesOrderItemList.Select(i => new InvoiceSalesOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/GetInvoiceListHandler.cs b/Features/Sales/Invoice/Cqrs/GetInvoiceListHandler.cs new file mode 100644 index 0000000..778fbe4 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/GetInvoiceListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class GetInvoiceListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public string? SalesOrderAutoNumber { get; set; } + public string? CustomerName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.InvoiceStatus InvoiceStatus { get; set; } +} + +public record GetInvoiceListQuery() : IRequest>; + +public class GetInvoiceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetInvoiceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetInvoiceListQuery request, CancellationToken cancellationToken) + { + return await _context.Invoice + .AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(so => so!.Customer) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetInvoiceListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + InvoiceDate = x.InvoiceDate, + SalesOrderAutoNumber = x.SalesOrder!.AutoNumber, + CustomerName = x.SalesOrder!.Customer!.Name, + AfterTaxAmount = x.SalesOrder!.AfterTaxAmount, + InvoiceStatus = x.InvoiceStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/GetInvoiceLookupHandler.cs b/Features/Sales/Invoice/Cqrs/GetInvoiceLookupHandler.cs new file mode 100644 index 0000000..169a6f7 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/GetInvoiceLookupHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class InvoiceLookupResponse +{ + public List SalesOrders { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetInvoiceLookupQuery() : IRequest; + +public class GetInvoiceLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetInvoiceLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetInvoiceLookupQuery request, CancellationToken cancellationToken) + { + var response = new InvoiceLookupResponse(); + + response.SalesOrders = await _context.SalesOrder.AsNoTracking() + .Where(x => x.OrderStatus == SalesOrderStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(InvoiceStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/GetSalesOrderDetailForInvoiceHandler.cs b/Features/Sales/Invoice/Cqrs/GetSalesOrderDetailForInvoiceHandler.cs new file mode 100644 index 0000000..89d022a --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/GetSalesOrderDetailForInvoiceHandler.cs @@ -0,0 +1,39 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public record GetSalesOrderDetailForInvoiceQuery(string SalesOrderId) : IRequest; + +public class GetSalesOrderDetailForInvoiceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesOrderDetailForInvoiceHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesOrderDetailForInvoiceQuery request, CancellationToken cancellationToken) + { + return await _context.SalesOrder + .AsNoTracking() + .Include(x => x.SalesOrderItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.SalesOrderId) + .Select(x => new GetInvoiceByIdResponse + { + SalesOrderId = x.Id, + BeforeTaxAmount = x.BeforeTaxAmount, + TaxAmount = x.TaxAmount, + AfterTaxAmount = x.AfterTaxAmount, + Items = x.SalesOrderItemList.Select(i => new InvoiceSalesOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/UpdateInvoiceHandler.cs b/Features/Sales/Invoice/Cqrs/UpdateInvoiceHandler.cs new file mode 100644 index 0000000..44a68a9 --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/UpdateInvoiceHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class UpdateInvoiceRequest +{ + public string? Id { get; set; } + public DateTime? InvoiceDate { get; set; } + public Data.Enums.InvoiceStatus InvoiceStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateInvoiceCommand(UpdateInvoiceRequest Data) : IRequest; + +public class UpdateInvoiceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateInvoiceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateInvoiceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Invoice + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.InvoiceDate = request.Data.InvoiceDate; + entity.InvoiceStatus = request.Data.InvoiceStatus; + entity.Description = request.Data.Description; + entity.SalesOrderId = request.Data.SalesOrderId; + + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/Cqrs/UpdateInvoiceValidator.cs b/Features/Sales/Invoice/Cqrs/UpdateInvoiceValidator.cs new file mode 100644 index 0000000..60ab53e --- /dev/null +++ b/Features/Sales/Invoice/Cqrs/UpdateInvoiceValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.Invoice.Cqrs; + +public class UpdateInvoiceValidator : AbstractValidator +{ + public UpdateInvoiceValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.SalesOrderId).NotEmpty().WithMessage("Sales Order is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/InvoiceEndpoint.cs b/Features/Sales/Invoice/InvoiceEndpoint.cs new file mode 100644 index 0000000..74008ae --- /dev/null +++ b/Features/Sales/Invoice/InvoiceEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.Invoice.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.Invoice; + +public static class InvoiceEndpoint +{ + public static void MapInvoiceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/invoice").WithTags("Invoices") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInvoiceListQuery()); + return result.ToApiResponse("Invoice list retrieved successfully"); + }) + .WithName("GetInvoiceList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetInvoiceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Invoice detail retrieved successfully" + : $"Invoice with ID {id} not found"); + }) + .WithName("GetInvoiceById"); + + group.MapGet("/sales-order-detail/{soId}", async (string soId, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesOrderDetailForInvoiceQuery(soId)); + return result.ToApiResponse(result is not null + ? "Sales order detail for invoice retrieved successfully" + : $"Sales order with ID {soId} not found"); + }) + .WithName("GetSalesOrderDetailForInvoice"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInvoiceLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetInvoiceLookup"); + + group.MapPost("/", async (CreateInvoiceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateInvoiceCommand(request)); + return result.ToApiResponse("Invoice has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateInvoice"); + + group.MapPost("/update", async (UpdateInvoiceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateInvoiceCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Invoice not found."); + } + return result.ToApiResponse("Invoice has been updated successfully"); + }) + .WithName("UpdateInvoice"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteInvoiceByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Invoice not found."); + } + return true.ToApiResponse("Invoice has been deleted successfully"); + }) + .WithName("DeleteInvoiceById"); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/InvoicePdfGenerator.cs b/Features/Sales/Invoice/InvoicePdfGenerator.cs new file mode 100644 index 0000000..6f9bb22 --- /dev/null +++ b/Features/Sales/Invoice/InvoicePdfGenerator.cs @@ -0,0 +1,130 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.Invoice.Cqrs; + +namespace Indotalent.Features.Sales.Invoice; + +public class InvoicePdfGenerator +{ + public static byte[] Generate(GetInvoiceByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("INVOICE") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + col.Item().Text($"SO Ref: {data.SalesOrderAutoNumber}").FontSize(9).FontColor(textSlate); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}, {data.CompanyState} {data.CompanyZipCode}").FontColor(textSlate); + col.Item().Text($"Tel: {data.CompanyPhone}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("BILL TO").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.CustomerName).Bold(); + c.Item().Text(data.CustomerStreet); + c.Item().Text(data.CustomerCity); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("INVOICE DETAILS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Invoice Date"); + r.RelativeItem().AlignRight().Text(data.InvoiceDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Status"); + r.RelativeItem().AlignRight().Text(data.InvoiceStatus.ToString().ToUpper()).Bold().FontColor(primaryBlue); + }); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(4); + columns.RelativeColumn(2); + columns.RelativeColumn(1.5f); + columns.RelativeColumn(2.5f); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Unit Price"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Total ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)).PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.UnitPrice?.ToString("N2")); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Quantity?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total?.ToString("N2")); + + static IContainer CellStyle(IContainer container) => container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Notes:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { r.RelativeItem().Text("Sub Total"); r.RelativeItem().AlignRight().Text(data.BeforeTaxAmount?.ToString("N2")); }); + c.Item().Row(r => { r.RelativeItem().Text("Tax Amount"); r.RelativeItem().AlignRight().Text(data.TaxAmount?.ToString("N2")); }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { r.RelativeItem().Text($"TOTAL").Bold(); r.RelativeItem().AlignRight().Text(data.AfterTaxAmount?.ToString("N2")).ExtraBold().FontColor(primaryBlue); }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => { x.Span("Page ").FontSize(8); x.CurrentPageNumber().FontSize(8); }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Sales/Invoice/InvoiceService.cs b/Features/Sales/Invoice/InvoiceService.cs new file mode 100644 index 0000000..3ea1902 --- /dev/null +++ b/Features/Sales/Invoice/InvoiceService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.Invoice.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.Invoice; + +public class InvoiceService : BaseService +{ + private readonly RestClient _client; + + public InvoiceService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetInvoiceListAsync() + { + var request = new RestRequest("api/invoice", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetInvoiceByIdAsync(string id) + { + var request = new RestRequest($"api/invoice/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesOrderDetailForInvoiceAsync(string soId) + { + var request = new RestRequest($"api/invoice/sales-order-detail/{soId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetInvoiceLookupAsync() + { + var request = new RestRequest("api/invoice/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateInvoiceAsync(CreateInvoiceRequest data) + { + var request = new RestRequest("api/invoice", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateInvoiceAsync(UpdateInvoiceRequest data) + { + var request = new RestRequest("api/invoice/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteInvoiceByIdAsync(string id) + { + var request = new RestRequest($"api/invoice/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Sales/InvoiceReport/Components/InvoiceReportPage.razor b/Features/Sales/InvoiceReport/Components/InvoiceReportPage.razor new file mode 100644 index 0000000..b33aa39 --- /dev/null +++ b/Features/Sales/InvoiceReport/Components/InvoiceReportPage.razor @@ -0,0 +1,8 @@ +@page "/sales/invoice-report" +@using Indotalent.Features.Sales.InvoiceReport.Components +@using MudBlazor + +<_InvoiceReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Sales/InvoiceReport/Components/_InvoiceReportDataTable.razor b/Features/Sales/InvoiceReport/Components/_InvoiceReportDataTable.razor new file mode 100644 index 0000000..cd0f1fd --- /dev/null +++ b/Features/Sales/InvoiceReport/Components/_InvoiceReportDataTable.razor @@ -0,0 +1,292 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.InvoiceReport +@using Indotalent.Features.Sales.InvoiceReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject InvoiceReportService InvoiceReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Invoice Report + Summary of customer billing, invoice dates, and payment status. +
+
+ + / + Sales + / + Invoice Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Customer + + + Invoice Number + + + Invoice Date + + + Sales Order + + + Total Amount + + + Status + + + + @context.CustomerName + @context.Number + @context.InvoiceDate?.ToString("yyyy-MM-dd") + @context.SalesOrderNumber + @context.AfterTaxAmount?.ToString("N2") + + @context.StatusName.ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await InvoiceReportService.GetInvoiceReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Number?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SalesOrderNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("InvoiceReports"); + var currentRow = 1; + + string[] headers = { "Customer", "Invoice Number", "Invoice Date", "Sales Order", "Total Amount", "Status" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.CustomerName; + worksheet.Cell(currentRow, 2).Value = item.Number; + worksheet.Cell(currentRow, 3).Value = item.InvoiceDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 4).Value = item.SalesOrderNumber; + worksheet.Cell(currentRow, 5).Value = item.AfterTaxAmount; + worksheet.Cell(currentRow, 6).Value = item.StatusName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Invoice_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Sales/InvoiceReport/Cqrs/GetInvoiceReportListHandler.cs b/Features/Sales/InvoiceReport/Cqrs/GetInvoiceReportListHandler.cs new file mode 100644 index 0000000..e2552de --- /dev/null +++ b/Features/Sales/InvoiceReport/Cqrs/GetInvoiceReportListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.InvoiceReport.Cqrs; + +public record GetInvoiceReportListDto +{ + public string? Id { get; init; } + public string? Number { get; init; } + public DateTime? InvoiceDate { get; init; } + public string? StatusName { get; init; } + public string? SalesOrderNumber { get; init; } + public decimal? AfterTaxAmount { get; init; } + public string? CustomerName { get; init; } +} + +public record GetInvoiceReportListQuery() : IRequest>; + +public class GetInvoiceReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetInvoiceReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetInvoiceReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.Invoice + .AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(x => x!.Customer) + .OrderByDescending(x => x.InvoiceDate) + .Select(x => new GetInvoiceReportListDto + { + Id = x.Id, + Number = x.AutoNumber, + InvoiceDate = x.InvoiceDate, + StatusName = x.InvoiceStatus.GetDescription(), + SalesOrderNumber = x.SalesOrder != null ? x.SalesOrder.AutoNumber : string.Empty, + AfterTaxAmount = x.SalesOrder != null ? x.SalesOrder.AfterTaxAmount : 0, + CustomerName = (x.SalesOrder != null && x.SalesOrder.Customer != null) ? x.SalesOrder.Customer.Name : string.Empty + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Sales/InvoiceReport/InvoiceReportEndpoint.cs b/Features/Sales/InvoiceReport/InvoiceReportEndpoint.cs new file mode 100644 index 0000000..a1e1dc6 --- /dev/null +++ b/Features/Sales/InvoiceReport/InvoiceReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.InvoiceReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.InvoiceReport; + +public static class InvoiceReportEndpoint +{ + public static void MapInvoiceReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/invoice-report").WithTags("InvoiceReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInvoiceReportListQuery()); + return result.ToApiResponse("Invoice report list retrieved successfully"); + }) + .WithName("GetInvoiceReportList"); + } +} \ No newline at end of file diff --git a/Features/Sales/InvoiceReport/InvoiceReportService.cs b/Features/Sales/InvoiceReport/InvoiceReportService.cs new file mode 100644 index 0000000..834d55e --- /dev/null +++ b/Features/Sales/InvoiceReport/InvoiceReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.InvoiceReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.InvoiceReport; + +public class InvoiceReportService : BaseService +{ + private readonly RestClient _client; + + public InvoiceReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetInvoiceReportListAsync() + { + var request = new RestRequest("api/invoice-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Components/PaymentReceivePage.razor b/Features/Sales/PaymentReceive/Components/PaymentReceivePage.razor new file mode 100644 index 0000000..1f00c9e --- /dev/null +++ b/Features/Sales/PaymentReceive/Components/PaymentReceivePage.razor @@ -0,0 +1,51 @@ +@page "/sales/payment-receive" +@using Indotalent.Features.Sales.PaymentReceive.Cqrs +@using Indotalent.Features.Sales.PaymentReceive.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PaymentReceiveCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PaymentReceiveUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PaymentReceiveDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdatePaymentReceiveRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePaymentReceiveRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Components/_PaymentReceiveCreateForm.razor b/Features/Sales/PaymentReceive/Components/_PaymentReceiveCreateForm.razor new file mode 100644 index 0000000..bc8c9fb --- /dev/null +++ b/Features/Sales/PaymentReceive/Components/_PaymentReceiveCreateForm.razor @@ -0,0 +1,216 @@ +@using Indotalent.Features.Sales.PaymentReceive.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PaymentReceiveService PaymentReceiveService +@inject ISnackbar Snackbar + + +
+ +
+ Add Payment Receive + Process customer payment for an existing invoice. +
+
+
+ + + + + Basic Information + + + Payment Date + + + + + Invoice Reference + + @foreach (var item in _lookup.Invoices) + { + @item.Name + } + + + + + Payment Method + + @foreach (var item in _lookup.PaymentMethods) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Payment Amount + + + + + Customer + + + + + Description + + + + + <_PaymentReceiveItemDataTable Items="_items" /> + + + + + + Invoice Commercial Summary +
+ Sub Total + @_invoiceSubTotal.ToString("N2") +
+
+ Tax Amount + @_invoiceTax.ToString("N2") +
+ +
+ Grand Total + @_invoiceGrandTotal.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Process Payment + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePaymentReceiveRequest _model = new() { PaymentDate = DateTime.Today, Status = Indotalent.Data.Enums.PaymentReceiveStatus.Draft }; + private PaymentReceiveLookupResponse _lookup = new(); + private List _items = new(); + + private string _customerName = ""; + private decimal _invoiceSubTotal = 0; + private decimal _invoiceTax = 0; + private decimal _invoiceGrandTotal = 0; + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PaymentReceiveService.GetPaymentReceiveLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnInvoiceChanged(string invoiceId) + { + _model.InvoiceId = invoiceId; + if (string.IsNullOrEmpty(invoiceId)) + { + ResetInvoiceData(); + return; + } + + var res = await PaymentReceiveService.GetInvoiceDetailForPaymentAsync(invoiceId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _customerName = res.Value.CustomerName ?? ""; + _invoiceSubTotal = res.Value.SalesOrderBeforeTaxAmount; + _invoiceTax = res.Value.SalesOrderTaxAmount; + _invoiceGrandTotal = res.Value.SalesOrderAfterTaxAmount; + + _model.PaymentAmount = res.Value.PaymentAmount; + + Snackbar.Add($"Invoice {res.Value.InvoiceNumber} items loaded.", Severity.Info); + } + } + + private void ResetInvoiceData() + { + _items.Clear(); + _customerName = ""; + _invoiceSubTotal = 0; + _invoiceTax = 0; + _invoiceGrandTotal = 0; + _model.PaymentAmount = 0; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PaymentReceiveService.CreatePaymentReceiveAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Payment recorded successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Components/_PaymentReceiveDataTable.razor b/Features/Sales/PaymentReceive/Components/_PaymentReceiveDataTable.razor new file mode 100644 index 0000000..d1293df --- /dev/null +++ b/Features/Sales/PaymentReceive/Components/_PaymentReceiveDataTable.razor @@ -0,0 +1,382 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.PaymentReceive +@using Indotalent.Features.Sales.PaymentReceive.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PaymentReceiveService PaymentReceiveService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Payment Receive + Record and manage customer payments for invoices. +
+
+ + / + Sales + / + Payment Receive +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Payment Receive + + } +
+
+ + + + + + Payment No + + + Date + + + Invoice Ref + + + Customer + + + Amount + + + Status + + + + + + + @context.AutoNumber + @context.PaymentDate?.ToString("yyyy-MM-dd") + @context.InvoiceNumber + @context.CustomerName + @context.PaymentAmount.ToString("N2") + + @context.Status.ToString().ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetPaymentReceiveListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await PaymentReceiveService.GetPaymentReceiveListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.InvoiceNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PaymentReceives"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Payment No"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "Invoice Ref"; + worksheet.Cell(currentRow, 4).Value = "Customer"; + worksheet.Cell(currentRow, 5).Value = "Amount"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.PaymentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.InvoiceNumber; + worksheet.Cell(currentRow, 4).Value = item.CustomerName; + worksheet.Cell(currentRow, 5).Value = item.PaymentAmount; + worksheet.Cell(currentRow, 6).Value = item.Status.ToString(); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "PaymentReceive_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await PaymentReceiveService.GetPaymentReceiveByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePaymentReceiveRequest + { + Id = detail.Id, + InvoiceId = detail.InvoiceId, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + PaymentDate = detail.PaymentDate, + PaymentMethodId = detail.PaymentMethodId, + PaymentAmount = detail.PaymentAmount, + Status = detail.Status, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await PaymentReceiveService.DeletePaymentReceiveByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Components/_PaymentReceiveItemDataTable.razor b/Features/Sales/PaymentReceive/Components/_PaymentReceiveItemDataTable.razor new file mode 100644 index 0000000..6f7f154 --- /dev/null +++ b/Features/Sales/PaymentReceive/Components/_PaymentReceiveItemDataTable.razor @@ -0,0 +1,38 @@ +@using Indotalent.Features.Sales.PaymentReceive.Cqrs +@using MudBlazor + + +
+ Referenced Invoice Items +
+ + + + Product + UnitPrice + Quantity + Total + + + + @context.ProductName + @if (!string.IsNullOrEmpty(context.Summary)) + { + @context.Summary + } + + @context.UnitPrice.ToString("N2") + @context.Quantity + @context.Total.ToString("N2") + + +
+ + + + +@code { + [Parameter] public List Items { get; set; } = new(); +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Components/_PaymentReceiveUpdateForm.razor b/Features/Sales/PaymentReceive/Components/_PaymentReceiveUpdateForm.razor new file mode 100644 index 0000000..4016c9b --- /dev/null +++ b/Features/Sales/PaymentReceive/Components/_PaymentReceiveUpdateForm.razor @@ -0,0 +1,302 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.PaymentReceive.Cqrs +@using Indotalent.Features.Sales.PaymentReceive.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PaymentReceiveService PaymentReceiveService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Payment Receive + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating Receipt... + } + else + { + Print Receipt + } + + } +
+ + + + + Basic Information + + + Payment Date + + + + + Invoice Reference + + @foreach (var item in _lookup.Invoices) + { + @item.Name + } + + + + + Payment Method + + @foreach (var item in _lookup.PaymentMethods) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Payment Amount + + + + + Customer + + + + + Description + + + + + <_PaymentReceiveItemDataTable Items="_items" /> + + + + + + Invoice Commercial Summary +
+ Sub Total + @_invoiceSubTotal.ToString("N2") +
+
+ Tax Amount + @_invoiceTax.ToString("N2") +
+ +
+ Grand Total + @_invoiceGrandTotal.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdatePaymentReceiveRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdatePaymentReceiveRequest _model = new(); + private List _items = new(); + private PaymentReceiveLookupResponse _lookup = new(); + + private string _customerName = ""; + private decimal _invoiceSubTotal = 0; + private decimal _invoiceTax = 0; + private decimal _invoiceGrandTotal = 0; + + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PaymentReceiveService.GetPaymentReceiveLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await LoadInvoiceDetails(_model.InvoiceId ?? ""); + } + + private async Task OnInvoiceChanged(string invoiceId) + { + if (ReadOnly || _model.InvoiceId == invoiceId) return; + _model.InvoiceId = invoiceId; + await LoadInvoiceDetails(invoiceId); + Snackbar.Add("Invoice items and amounts updated.", Severity.Info); + } + + private async Task LoadInvoiceDetails(string invoiceId) + { + if (string.IsNullOrEmpty(invoiceId)) return; + + try + { + var response = await PaymentReceiveService.GetInvoiceDetailForPaymentAsync(invoiceId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items; + _customerName = response.Value.CustomerName ?? ""; + _invoiceSubTotal = response.Value.SalesOrderBeforeTaxAmount; + _invoiceTax = response.Value.SalesOrderTaxAmount; + _invoiceGrandTotal = response.Value.SalesOrderAfterTaxAmount; + + if (!ReadOnly) + { + _model.PaymentAmount = response.Value.PaymentAmount; + } + + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + await Task.Delay(1000); + + var response = await PaymentReceiveService.GetPaymentReceiveByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PaymentReceivePdfGenerator.Generate(response.Value); + var fileName = $"Receipt_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("Receipt generated successfully.", Severity.Success); + } + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PaymentReceiveService.UpdatePaymentReceiveAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveHandler.cs b/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveHandler.cs new file mode 100644 index 0000000..f06d7ad --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class CreatePaymentReceiveRequest +{ + public string? InvoiceId { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public decimal? PaymentAmount { get; set; } + public Data.Enums.PaymentReceiveStatus Status { get; set; } +} + +public class CreatePaymentReceiveResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePaymentReceiveCommand(CreatePaymentReceiveRequest Data) : IRequest; + +public class CreatePaymentReceiveHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePaymentReceiveHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePaymentReceiveCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PaymentReceive); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"PAY/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PaymentReceive + { + AutoNumber = autoNo, + InvoiceId = request.Data.InvoiceId, + Description = request.Data.Description, + PaymentDate = request.Data.PaymentDate, + PaymentMethodId = request.Data.PaymentMethodId, + PaymentAmount = request.Data.PaymentAmount ?? 0, + Status = request.Data.Status + }; + + _context.PaymentReceive.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePaymentReceiveResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveValidator.cs b/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveValidator.cs new file mode 100644 index 0000000..2f9c61b --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/CreatePaymentReceiveValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class CreatePaymentReceiveValidator : AbstractValidator +{ + public CreatePaymentReceiveValidator() + { + RuleFor(x => x.InvoiceId).NotEmpty().WithMessage("Invoice reference is required"); + RuleFor(x => x.PaymentDate).NotEmpty().WithMessage("Payment date is required"); + RuleFor(x => x.PaymentMethodId).NotEmpty().WithMessage("Payment method is required"); + RuleFor(x => x.PaymentAmount).GreaterThan(0).WithMessage("Amount must be greater than 0"); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/DeletePaymentReceiveByIdHandler.cs b/Features/Sales/PaymentReceive/Cqrs/DeletePaymentReceiveByIdHandler.cs new file mode 100644 index 0000000..d20f2c9 --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/DeletePaymentReceiveByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public record DeletePaymentReceiveByIdCommand(string Id) : IRequest; + +public class DeletePaymentReceiveByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePaymentReceiveByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePaymentReceiveByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PaymentReceive + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.PaymentReceive.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/GetInvoiceDetailForPaymentHandler.cs b/Features/Sales/PaymentReceive/Cqrs/GetInvoiceDetailForPaymentHandler.cs new file mode 100644 index 0000000..8fc812e --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/GetInvoiceDetailForPaymentHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public record GetInvoiceDetailForPaymentQuery(string InvoiceId) : IRequest; + +public class GetInvoiceDetailForPaymentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetInvoiceDetailForPaymentHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetInvoiceDetailForPaymentQuery request, CancellationToken cancellationToken) + { + var invoice = await _context.Invoice + .AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(so => so!.Customer) + .Include(x => x.SalesOrder) + .ThenInclude(so => so!.SalesOrderItemList) + .ThenInclude(si => si.Product) + .FirstOrDefaultAsync(x => x.Id == request.InvoiceId, cancellationToken); + + if (invoice == null) return null; + + var salesOrder = invoice.SalesOrder; + + return new GetPaymentReceiveByIdResponse + { + InvoiceId = invoice.Id, + InvoiceNumber = invoice.AutoNumber, + CustomerName = salesOrder?.Customer?.Name, + CustomerStreet = salesOrder?.Customer?.Street, + CustomerCity = salesOrder?.Customer?.City, + SalesOrderBeforeTaxAmount = salesOrder?.BeforeTaxAmount ?? 0, + SalesOrderTaxAmount = salesOrder?.TaxAmount ?? 0, + SalesOrderAfterTaxAmount = salesOrder?.AfterTaxAmount ?? 0, + PaymentAmount = salesOrder?.AfterTaxAmount ?? 0, + Items = salesOrder?.SalesOrderItemList.Select(x => new PaymentReceiveItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product?.Name, + Summary = x.Summary, + UnitPrice = x.UnitPrice ?? 0, + Quantity = x.Quantity ?? 0, + Total = x.Total ?? 0 + }).ToList() ?? new() + }; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveByIdHandler.cs b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveByIdHandler.cs new file mode 100644 index 0000000..f7f5756 --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveByIdHandler.cs @@ -0,0 +1,116 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class PaymentReceiveItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal UnitPrice { get; set; } + public double Quantity { get; set; } + public decimal Total { get; set; } +} + +public class GetPaymentReceiveByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? InvoiceId { get; set; } + public string? InvoiceNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public string? PaymentMethodName { get; set; } + public decimal PaymentAmount { get; set; } + public Data.Enums.PaymentReceiveStatus Status { get; set; } + public string? CustomerName { get; set; } + public string? CustomerStreet { get; set; } + public string? CustomerCity { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CurrencySymbol { get; set; } + public decimal SalesOrderBeforeTaxAmount { get; set; } + public decimal SalesOrderTaxAmount { get; set; } + public decimal SalesOrderAfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetPaymentReceiveByIdQuery(string Id) : IRequest; + +public class GetPaymentReceiveByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPaymentReceiveByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPaymentReceiveByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + var payment = await _context.PaymentReceive + .AsNoTracking() + .Include(x => x.Invoice) + .ThenInclude(i => i!.SalesOrder) + .ThenInclude(so => so!.Customer) + .Include(x => x.Invoice) + .ThenInclude(i => i!.SalesOrder) + .ThenInclude(so => so!.SalesOrderItemList) + .ThenInclude(si => si.Product) + .Include(x => x.PaymentMethod) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (payment == null) return null; + + var salesOrder = payment.Invoice?.SalesOrder; + + return new GetPaymentReceiveByIdResponse + { + Id = payment.Id, + AutoNumber = payment.AutoNumber, + InvoiceId = payment.InvoiceId, + InvoiceNumber = payment.Invoice?.AutoNumber, + Description = payment.Description, + PaymentDate = payment.PaymentDate, + PaymentMethodId = payment.PaymentMethodId, + PaymentMethodName = payment.PaymentMethod?.Name, + PaymentAmount = payment.PaymentAmount ?? 0, + Status = payment.Status, + CustomerName = salesOrder?.Customer?.Name, + CustomerStreet = salesOrder?.Customer?.Street, + CustomerCity = salesOrder?.Customer?.City, + CompanyName = company?.Name, + CompanyStreet = company?.StreetAddress, + CompanyCity = company?.City, + CurrencySymbol = company?.Currency?.Symbol ?? "IDR", + SalesOrderBeforeTaxAmount = salesOrder?.BeforeTaxAmount ?? 0, + SalesOrderTaxAmount = salesOrder?.TaxAmount ?? 0, + SalesOrderAfterTaxAmount = salesOrder?.AfterTaxAmount ?? 0, + CreatedAt = payment.CreatedAt, + CreatedBy = payment.CreatedBy, + UpdatedAt = payment.UpdatedAt, + UpdatedBy = payment.UpdatedBy, + Items = salesOrder?.SalesOrderItemList.Select(x => new PaymentReceiveItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product?.Name, + Summary = x.Summary, + UnitPrice = x.UnitPrice ?? 0, + Quantity = x.Quantity ?? 0, + Total = x.Total ?? 0 + }).ToList() ?? new() + }; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveListHandler.cs b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveListHandler.cs new file mode 100644 index 0000000..33023b5 --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class GetPaymentReceiveListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? PaymentDate { get; set; } + public string? InvoiceNumber { get; set; } + public string? CustomerName { get; set; } + public decimal PaymentAmount { get; set; } + public Data.Enums.PaymentReceiveStatus Status { get; set; } +} + +public record GetPaymentReceiveListQuery() : IRequest>; + +public class GetPaymentReceiveListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPaymentReceiveListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPaymentReceiveListQuery request, CancellationToken cancellationToken) + { + return await _context.PaymentReceive + .AsNoTracking() + .Include(x => x.Invoice) + .ThenInclude(i => i!.SalesOrder) + .ThenInclude(so => so!.Customer) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPaymentReceiveListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + PaymentDate = x.PaymentDate, + InvoiceNumber = x.Invoice!.AutoNumber, + CustomerName = x.Invoice!.SalesOrder!.Customer!.Name, + PaymentAmount = x.PaymentAmount ?? 0, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveLookupHandler.cs b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveLookupHandler.cs new file mode 100644 index 0000000..567b1ab --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/GetPaymentReceiveLookupHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class PaymentReceiveLookupResponse +{ + public List Invoices { get; set; } = new(); + public List PaymentMethods { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetPaymentReceiveLookupQuery() : IRequest; + +public class GetPaymentReceiveLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPaymentReceiveLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPaymentReceiveLookupQuery request, CancellationToken cancellationToken) + { + var response = new PaymentReceiveLookupResponse(); + + response.Invoices = await _context.Invoice.AsNoTracking() + .Where(x => x.InvoiceStatus == InvoiceStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.PaymentMethods = await _context.PaymentMethod.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(PaymentReceiveStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveHandler.cs b/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveHandler.cs new file mode 100644 index 0000000..f10fcde --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class UpdatePaymentReceiveRequest +{ + public string? Id { get; set; } + public string? InvoiceId { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public DateTime? PaymentDate { get; set; } + public string? PaymentMethodId { get; set; } + public decimal? PaymentAmount { get; set; } + public Data.Enums.PaymentReceiveStatus Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdatePaymentReceiveCommand(UpdatePaymentReceiveRequest Data) : IRequest; + +public class UpdatePaymentReceiveHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePaymentReceiveHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePaymentReceiveCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PaymentReceive + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.InvoiceId = request.Data.InvoiceId; + entity.Description = request.Data.Description; + entity.PaymentDate = request.Data.PaymentDate; + entity.PaymentMethodId = request.Data.PaymentMethodId; + entity.PaymentAmount = request.Data.PaymentAmount ?? 0; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveValidator.cs b/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveValidator.cs new file mode 100644 index 0000000..9b4cf80 --- /dev/null +++ b/Features/Sales/PaymentReceive/Cqrs/UpdatePaymentReceiveValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.PaymentReceive.Cqrs; + +public class UpdatePaymentReceiveValidator : AbstractValidator +{ + public UpdatePaymentReceiveValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.InvoiceId).NotEmpty().WithMessage("Invoice reference is required"); + RuleFor(x => x.PaymentDate).NotEmpty().WithMessage("Payment date is required"); + RuleFor(x => x.PaymentAmount).GreaterThan(0).WithMessage("Amount must be greater than 0"); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/PaymentReceiveEndpoint.cs b/Features/Sales/PaymentReceive/PaymentReceiveEndpoint.cs new file mode 100644 index 0000000..e4d60e8 --- /dev/null +++ b/Features/Sales/PaymentReceive/PaymentReceiveEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.PaymentReceive.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.PaymentReceive; + +public static class PaymentReceiveEndpoint +{ + public static void MapPaymentReceiveEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/payment-receive").WithTags("PaymentReceives") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentReceiveListQuery()); + return result.ToApiResponse("Payment receive list retrieved successfully"); + }) + .WithName("GetPaymentReceiveList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentReceiveByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Payment receive detail retrieved successfully" + : $"Payment receive with ID {id} not found"); + }) + .WithName("GetPaymentReceiveById"); + + group.MapGet("/invoice-detail/{invoiceId}", async (string invoiceId, IMediator mediator) => + { + var result = await mediator.Send(new GetInvoiceDetailForPaymentQuery(invoiceId)); + return result.ToApiResponse(result is not null + ? "Invoice detail for payment retrieved successfully" + : $"Invoice with ID {invoiceId} not found"); + }) + .WithName("GetInvoiceDetailForPayment"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentReceiveLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPaymentReceiveLookup"); + + group.MapPost("/", async (CreatePaymentReceiveRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePaymentReceiveCommand(request)); + return result.ToApiResponse("Payment receive has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePaymentReceive"); + + group.MapPost("/update", async (UpdatePaymentReceiveRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePaymentReceiveCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Payment receive not found."); + } + return result.ToApiResponse("Payment receive has been updated successfully"); + }) + .WithName("UpdatePaymentReceive"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePaymentReceiveByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Payment receive not found."); + } + return true.ToApiResponse("Payment receive has been deleted successfully"); + }) + .WithName("DeletePaymentReceiveById"); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/PaymentReceivePdfGenerator.cs b/Features/Sales/PaymentReceive/PaymentReceivePdfGenerator.cs new file mode 100644 index 0000000..2a99800 --- /dev/null +++ b/Features/Sales/PaymentReceive/PaymentReceivePdfGenerator.cs @@ -0,0 +1,127 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.PaymentReceive.Cqrs; + +namespace Indotalent.Features.Sales.PaymentReceive; + +public class PaymentReceivePdfGenerator +{ + public static byte[] Generate(GetPaymentReceiveByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("PAYMENT RECEIPT") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("RECEIVED FROM").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.CustomerName).Bold(); + c.Item().Text(data.CustomerStreet); + c.Item().Text(data.CustomerCity); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("PAYMENT DETAILS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Payment Date"); + r.RelativeItem().AlignRight().Text(data.PaymentDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Payment Method"); + r.RelativeItem().AlignRight().Text(data.PaymentMethodName); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Invoice Ref"); + r.RelativeItem().AlignRight().Text(data.InvoiceNumber).Bold(); + }); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(6); + columns.RelativeColumn(3); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Item Description"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Total ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)).PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total.ToString("N2")); + + static IContainer CellStyle(IContainer container) => container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Description / Notes:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { r.RelativeItem().Text("Sub Total"); r.RelativeItem().AlignRight().Text(data.SalesOrderBeforeTaxAmount.ToString("N2")); }); + c.Item().Row(r => { r.RelativeItem().Text("Tax Amount"); r.RelativeItem().AlignRight().Text(data.SalesOrderTaxAmount.ToString("N2")); }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { r.RelativeItem().Text($"TOTAL INVOICE").Bold(); r.RelativeItem().AlignRight().Text(data.SalesOrderAfterTaxAmount.ToString("N2")).ExtraBold(); }); + c.Item().PaddingTop(5).Background("#F1F5F9").PaddingHorizontal(5).Row(r => { r.RelativeItem().Text($"AMOUNT PAID").Bold().FontColor(primaryBlue); r.RelativeItem().AlignRight().Text(data.PaymentAmount.ToString("N2")).ExtraBold().FontColor(primaryBlue); }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => { x.Span("Page ").FontSize(8); x.CurrentPageNumber().FontSize(8); }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceive/PaymentReceiveService.cs b/Features/Sales/PaymentReceive/PaymentReceiveService.cs new file mode 100644 index 0000000..9d18f39 --- /dev/null +++ b/Features/Sales/PaymentReceive/PaymentReceiveService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.PaymentReceive.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.PaymentReceive; + +public class PaymentReceiveService : BaseService +{ + private readonly RestClient _client; + + public PaymentReceiveService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPaymentReceiveListAsync() + { + var request = new RestRequest("api/payment-receive", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPaymentReceiveByIdAsync(string id) + { + var request = new RestRequest($"api/payment-receive/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetInvoiceDetailForPaymentAsync(string invoiceId) + { + var request = new RestRequest($"api/payment-receive/invoice-detail/{invoiceId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPaymentReceiveLookupAsync() + { + var request = new RestRequest("api/payment-receive/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePaymentReceiveAsync(CreatePaymentReceiveRequest data) + { + var request = new RestRequest("api/payment-receive", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePaymentReceiveAsync(UpdatePaymentReceiveRequest data) + { + var request = new RestRequest("api/payment-receive/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePaymentReceiveByIdAsync(string id) + { + var request = new RestRequest($"api/payment-receive/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceiveReport/Components/PaymentReceiveReportPage.razor b/Features/Sales/PaymentReceiveReport/Components/PaymentReceiveReportPage.razor new file mode 100644 index 0000000..f19ffa8 --- /dev/null +++ b/Features/Sales/PaymentReceiveReport/Components/PaymentReceiveReportPage.razor @@ -0,0 +1,8 @@ +@page "/sales/payment-receive-report" +@using Indotalent.Features.Sales.PaymentReceiveReport.Components +@using MudBlazor + +<_PaymentReceiveReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceiveReport/Components/_PaymentReceiveReportDataTable.razor b/Features/Sales/PaymentReceiveReport/Components/_PaymentReceiveReportDataTable.razor new file mode 100644 index 0000000..1119434 --- /dev/null +++ b/Features/Sales/PaymentReceiveReport/Components/_PaymentReceiveReportDataTable.razor @@ -0,0 +1,297 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.PaymentReceiveReport +@using Indotalent.Features.Sales.PaymentReceiveReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PaymentReceiveReportService PaymentReceiveReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Payment Receive Report + Detailed log of all incoming customer payments and collections. +
+
+ + / + Sales + / + Payment Receive Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Customer + + + Payment Number + + + Payment Date + + + Invoice + + + Method + + + Amount + + + Status + + + + @context.CustomerName + @context.Number + @context.PaymentDate?.ToString("yyyy-MM-dd") + @context.InvoiceNumber + @context.PaymentMethodName + @context.PaymentAmount?.ToString("N2") + + @context.StatusName.ToUpper() + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await PaymentReceiveReportService.GetPaymentReceiveReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Number?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.InvoiceNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PaymentReports"); + var currentRow = 1; + + string[] headers = { "Customer", "Payment Number", "Payment Date", "Invoice", "Method", "Amount", "Status" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.CustomerName; + worksheet.Cell(currentRow, 2).Value = item.Number; + worksheet.Cell(currentRow, 3).Value = item.PaymentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 4).Value = item.InvoiceNumber; + worksheet.Cell(currentRow, 5).Value = item.PaymentMethodName; + worksheet.Cell(currentRow, 6).Value = item.PaymentAmount; + worksheet.Cell(currentRow, 7).Value = item.StatusName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Payment_Receive_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceiveReport/Cqrs/GetPaymentReceiveReportListHandler.cs b/Features/Sales/PaymentReceiveReport/Cqrs/GetPaymentReceiveReportListHandler.cs new file mode 100644 index 0000000..9d2191b --- /dev/null +++ b/Features/Sales/PaymentReceiveReport/Cqrs/GetPaymentReceiveReportListHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.PaymentReceiveReport.Cqrs; + +public record GetPaymentReceiveReportListDto +{ + public string? Id { get; init; } + public string? Number { get; init; } + public DateTime? PaymentDate { get; init; } + public string? PaymentMethodName { get; init; } + public decimal? PaymentAmount { get; init; } + public string? StatusName { get; init; } + public string? InvoiceNumber { get; init; } + public string? CustomerName { get; init; } +} + +public record GetPaymentReceiveReportListQuery() : IRequest>; + +public class GetPaymentReceiveReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPaymentReceiveReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetPaymentReceiveReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.PaymentReceive + .AsNoTracking() + .Include(x => x.PaymentMethod) + .Include(x => x.Invoice) + .ThenInclude(x => x!.SalesOrder) + .ThenInclude(x => x!.Customer) + .OrderByDescending(x => x.PaymentDate) + .Select(x => new GetPaymentReceiveReportListDto + { + Id = x.Id, + Number = x.AutoNumber, + PaymentDate = x.PaymentDate, + PaymentMethodName = x.PaymentMethod != null ? x.PaymentMethod.Name : string.Empty, + PaymentAmount = x.PaymentAmount, + StatusName = x.Status.GetDescription(), + InvoiceNumber = x.Invoice != null ? x.Invoice.AutoNumber : string.Empty, + CustomerName = (x.Invoice != null && x.Invoice.SalesOrder != null && x.Invoice.SalesOrder.Customer != null) + ? x.Invoice.SalesOrder.Customer.Name : string.Empty + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceiveReport/PaymentReceiveReportEndpoint.cs b/Features/Sales/PaymentReceiveReport/PaymentReceiveReportEndpoint.cs new file mode 100644 index 0000000..04fa4e6 --- /dev/null +++ b/Features/Sales/PaymentReceiveReport/PaymentReceiveReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.PaymentReceiveReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.PaymentReceiveReport; + +public static class PaymentReceiveReportEndpoint +{ + public static void MapPaymentReceiveReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/payment-receive-report").WithTags("PaymentReceiveReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPaymentReceiveReportListQuery()); + return result.ToApiResponse("Payment receive report list retrieved successfully"); + }) + .WithName("GetPaymentReceiveReportList"); + } +} \ No newline at end of file diff --git a/Features/Sales/PaymentReceiveReport/PaymentReceiveReportService.cs b/Features/Sales/PaymentReceiveReport/PaymentReceiveReportService.cs new file mode 100644 index 0000000..183ff21 --- /dev/null +++ b/Features/Sales/PaymentReceiveReport/PaymentReceiveReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.PaymentReceiveReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.PaymentReceiveReport; + +public class PaymentReceiveReportService : BaseService +{ + private readonly RestClient _client; + + public PaymentReceiveReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPaymentReceiveReportListAsync() + { + var request = new RestRequest("api/payment-receive-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/SalesOrderPage.razor b/Features/Sales/SalesOrder/Components/SalesOrderPage.razor new file mode 100644 index 0000000..eaa06cc --- /dev/null +++ b/Features/Sales/SalesOrder/Components/SalesOrderPage.razor @@ -0,0 +1,51 @@ +@page "/sales/sales-order" +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using Indotalent.Features.Sales.SalesOrder.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SalesOrderCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SalesOrderUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_SalesOrderDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateSalesOrderRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateSalesOrderRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderCreateForm.razor b/Features/Sales/SalesOrder/Components/_SalesOrderCreateForm.razor new file mode 100644 index 0000000..75a26b9 --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderCreateForm.razor @@ -0,0 +1,141 @@ +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesOrderService SalesOrderService +@inject ISnackbar Snackbar + + +
+ +
+ Add Sales Order + Create a new master sales order. +
+
+
+ + + + + Basic Information + + + Order Date + + + + + Customer + + @foreach (var item in _lookup.Customers) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Tax + + @foreach (var item in _lookup.Taxes) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Order + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateSalesOrderValidator _validator = new(); + private CreateSalesOrderRequest _model = new() { OrderDate = DateTime.Today }; + private SalesOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesOrderService.GetSalesOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await SalesOrderService.CreateSalesOrderAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderDataTable.razor b/Features/Sales/SalesOrder/Components/_SalesOrderDataTable.razor new file mode 100644 index 0000000..1f50106 --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderDataTable.razor @@ -0,0 +1,377 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesOrder +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesOrderService SalesOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Order + Manage customer orders and fulfillment process. +
+
+ + / + Sales + / + Sales Order +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Sales Order + + } +
+
+ + + + + + No + + + Date + + + Customer + + + Status + + + Total Amount + + + + + + + @context.AutoNumber + @context.OrderDate?.ToString("yyyy-MM-dd") + @context.CustomerName + + @context.OrderStatus.ToString().ToUpper() + + @context.AfterTaxAmount?.ToString("N2") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetSalesOrderListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await SalesOrderService.GetSalesOrderListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("SalesOrders"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Auto Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "Customer"; + worksheet.Cell(currentRow, 4).Value = "Status"; + worksheet.Cell(currentRow, 5).Value = "Total Amount"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.OrderDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.CustomerName; + worksheet.Cell(currentRow, 4).Value = item.OrderStatus.ToString(); + worksheet.Cell(currentRow, 5).Value = item.AfterTaxAmount; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "SO_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var request = await MapToUpdateRequest(_selectedItem.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await SalesOrderService.GetSalesOrderByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateSalesOrderRequest + { + Id = detail.Id, + OrderDate = detail.OrderDate, + OrderStatus = detail.OrderStatus, + Description = detail.Description, + CustomerId = detail.CustomerId, + TaxId = detail.TaxId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber, + BeforeTaxAmount = detail.BeforeTaxAmount, + TaxAmount = detail.TaxAmount, + AfterTaxAmount = detail.AfterTaxAmount + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.AutoNumber } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await SalesOrderService.DeleteSalesOrderByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderItemCreateForm.razor b/Features/Sales/SalesOrder/Components/_SalesOrderItemCreateForm.razor new file mode 100644 index 0000000..d52b1c1 --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderItemCreateForm.razor @@ -0,0 +1,86 @@ +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using MudBlazor +@inject SalesOrderService SalesOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Unit Price + + + + Quantity + + + + Summary + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string SalesOrderId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateSalesOrderItemRequest _model = new() { Quantity = 1 }; + private SalesOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesOrderService.GetSalesOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.SalesOrderId = SalesOrderId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesOrderService.CreateSalesOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Item added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderItemDataTable.razor b/Features/Sales/SalesOrder/Components/_SalesOrderItemDataTable.razor new file mode 100644 index 0000000..1135504 --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderItemDataTable.razor @@ -0,0 +1,92 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesOrder +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject SalesOrderService SalesOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Order Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + Unit Price + Quantity + Total + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.UnitPrice?.ToString("N2") + @context.Quantity + @context.Total?.ToString("N2") + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string SalesOrderId { get; set; } = string.Empty; + [Parameter] public List Items { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["SalesOrderId"] = SalesOrderId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesOrderItemCreateForm>("Add Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(SalesOrderItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesOrderItemUpdateForm>("Edit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(SalesOrderItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this item?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await SalesOrderService.DeleteSalesOrderItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderItemUpdateForm.razor b/Features/Sales/SalesOrder/Components/_SalesOrderItemUpdateForm.razor new file mode 100644 index 0000000..7e9a60e --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderItemUpdateForm.razor @@ -0,0 +1,96 @@ +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using MudBlazor +@inject SalesOrderService SalesOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Unit Price + + + + Quantity + + + + Summary + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public SalesOrderItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateSalesOrderItemRequest _model = new(); + private SalesOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesOrderService.GetSalesOrderLookupAsync(); + if (res != null && res.IsSuccess) + { + _lookup = res.Value ?? new(); + } + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.Summary = Data.Summary; + _model.UnitPrice = Data.UnitPrice; + _model.Quantity = Data.Quantity; + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesOrderService.UpdateSalesOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Item updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Components/_SalesOrderUpdateForm.razor b/Features/Sales/SalesOrder/Components/_SalesOrderUpdateForm.razor new file mode 100644 index 0000000..8ff085c --- /dev/null +++ b/Features/Sales/SalesOrder/Components/_SalesOrderUpdateForm.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesOrder.Cqrs +@using Indotalent.Features.Sales.SalesOrder.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject SalesOrderService SalesOrderService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Order + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Order Date + + + + + Customer + + @foreach (var item in _lookup.Customers) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Tax + + @foreach (var item in _lookup.Taxes) + { + @item.Name + } + + + + + Description + + + + + <_SalesOrderItemDataTable Items="_items" SalesOrderId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Order Summary +
+ Sub Total + @_model.BeforeTaxAmount?.ToString("N2") +
+
+ Tax Amount + @_model.TaxAmount?.ToString("N2") +
+ +
+ Total Amount + @_model.AfterTaxAmount?.ToString("N2") +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateSalesOrderRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateSalesOrderValidator _validator = new(); + private UpdateSalesOrderRequest _model = new(); + private List _items = new(); + private SalesOrderLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await SalesOrderService.GetSalesOrderLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnTaxChanged(string newTaxId) + { + if (ReadOnly || _model.TaxId == newTaxId) return; + + _model.TaxId = newTaxId; + StateHasChanged(); + + await SubmitInternal(false); + Snackbar.Add("Tax updated and totals recalculated.", Severity.Info); + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await SalesOrderService.GetSalesOrderByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + _model.BeforeTaxAmount = response.Value.BeforeTaxAmount; + _model.TaxAmount = response.Value.TaxAmount; + _model.AfterTaxAmount = response.Value.AfterTaxAmount; + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DownloadPdf() + { + try + { + _isPrinting = true; + StateHasChanged(); + + await Task.Delay(1000); + + var response = await SalesOrderService.GetSalesOrderByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = SalesOrderPdfGenerator.Generate(response.Value); + var fileName = $"SO_{response.Value.AutoNumber}.pdf"; + var base64 = Convert.ToBase64String(pdfBytes); + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", base64); + Snackbar.Add("PDF generated successfully.", Severity.Success); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _isPrinting = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + await SubmitInternal(true); + } + + private async Task SubmitInternal(bool showNotification) + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await SalesOrderService.UpdateSalesOrderAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + if (showNotification) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderHandler.cs b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderHandler.cs new file mode 100644 index 0000000..607b666 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class CreateSalesOrderRequest +{ + public DateTime? OrderDate { get; set; } + public Data.Enums.SalesOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public string? TaxId { get; set; } +} + +public class CreateSalesOrderResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateSalesOrderCommand(CreateSalesOrderRequest Data) : IRequest; + +public class CreateSalesOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesOrderCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.SalesOrder); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"SO/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.SalesOrder + { + AutoNumber = autoNo, + OrderDate = request.Data.OrderDate, + OrderStatus = request.Data.OrderStatus, + Description = request.Data.Description, + CustomerId = request.Data.CustomerId, + TaxId = request.Data.TaxId, + BeforeTaxAmount = 0, + TaxAmount = 0, + AfterTaxAmount = 0 + }; + + _context.SalesOrder.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesOrderResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderItemHandler.cs b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderItemHandler.cs new file mode 100644 index 0000000..519bc34 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderItemHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class CreateSalesOrderItemRequest +{ + public string? SalesOrderId { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record CreateSalesOrderItemCommand(CreateSalesOrderItemRequest Data) : IRequest; + +public class CreateSalesOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.SalesOrderItem + { + SalesOrderId = request.Data.SalesOrderId, + ProductId = request.Data.ProductId, + Summary = request.Data.Summary, + UnitPrice = request.Data.UnitPrice ?? 0, + Quantity = request.Data.Quantity ?? 0, + Total = (request.Data.UnitPrice ?? 0) * (decimal)(request.Data.Quantity ?? 0) + }; + + _context.SalesOrderItem.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(request.Data.SalesOrderId)) + { + SalesOrderHelper.Recalculate(_context, request.Data.SalesOrderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderValidator.cs b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderValidator.cs new file mode 100644 index 0000000..322f553 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/CreateSalesOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class CreateSalesOrderValidator : AbstractValidator +{ + public CreateSalesOrderValidator() + { + RuleFor(x => x.OrderDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.CustomerId).NotEmpty().WithMessage("Customer is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderByIdHandler.cs b/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderByIdHandler.cs new file mode 100644 index 0000000..d0748c9 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public record DeleteSalesOrderByIdCommand(string Id) : IRequest; + +public class DeleteSalesOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesOrderByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesOrder + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.SalesOrder.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderItemHandler.cs b/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderItemHandler.cs new file mode 100644 index 0000000..9dc0181 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/DeleteSalesOrderItemHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public record DeleteSalesOrderItemCommand(string Id) : IRequest; + +public class DeleteSalesOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesOrderItem + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var salesOrderId = entity.SalesOrderId; + + _context.SalesOrderItem.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(salesOrderId)) + { + SalesOrderHelper.Recalculate(_context, salesOrderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/GetSalesOrderByIdHandler.cs b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderByIdHandler.cs new file mode 100644 index 0000000..c1588d3 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderByIdHandler.cs @@ -0,0 +1,116 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class SalesOrderItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } + public decimal? Total { get; set; } +} + +public class GetSalesOrderByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public Data.Enums.SalesOrderStatus OrderStatus { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public string? CustomerName { get; set; } + public string? CustomerStreet { get; set; } + public string? CustomerCity { get; set; } + public string? CustomerState { get; set; } + public string? CustomerZipCode { get; set; } + public string? CustomerPhone { get; set; } + public string? CustomerEmail { get; set; } + public string? CompanyName { get; set; } + public string? CompanyStreet { get; set; } + public string? CompanyCity { get; set; } + public string? CompanyState { get; set; } + public string? CompanyZipCode { get; set; } + public string? CompanyPhone { get; set; } + public string? CompanyEmail { get; set; } + public string? CurrencySymbol { get; set; } + public string? TaxId { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Items { get; set; } = new(); +} + +public record GetSalesOrderByIdQuery(string Id) : IRequest; + +public class GetSalesOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesOrderByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.SalesOrder + .AsNoTracking() + .Include(x => x.Customer) + .Include(x => x.SalesOrderItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetSalesOrderByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + OrderDate = x.OrderDate, + OrderStatus = x.OrderStatus, + Description = x.Description, + CustomerId = x.CustomerId, + CustomerName = x.Customer!.Name, + CustomerStreet = x.Customer!.Street, + CustomerCity = x.Customer!.City, + CustomerState = x.Customer!.State, + CustomerZipCode = x.Customer!.ZipCode, + CustomerPhone = x.Customer!.PhoneNumber, + CustomerEmail = x.Customer!.EmailAddress, + CompanyName = company != null ? company.Name : string.Empty, + CompanyStreet = company != null ? company.StreetAddress : string.Empty, + CompanyCity = company != null ? company.City : string.Empty, + CompanyState = company != null ? company.StateProvince : string.Empty, + CompanyZipCode = company != null ? company.ZipCode : string.Empty, + CompanyPhone = company != null ? company.Phone : string.Empty, + CompanyEmail = company != null ? company.Email : string.Empty, + CurrencySymbol = company != null && company.Currency != null ? company.Currency.Symbol : "IDR", + TaxId = x.TaxId, + BeforeTaxAmount = x.BeforeTaxAmount, + TaxAmount = x.TaxAmount, + AfterTaxAmount = x.AfterTaxAmount, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Items = x.SalesOrderItemList.Select(i => new SalesOrderItemResponse + { + Id = i.Id, + ProductId = i.ProductId, + ProductName = i.Product!.Name, + Summary = i.Summary, + UnitPrice = i.UnitPrice, + Quantity = i.Quantity, + Total = i.Total + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/GetSalesOrderListHandler.cs b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderListHandler.cs new file mode 100644 index 0000000..b1abfb5 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class GetSalesOrderListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? OrderDate { get; set; } + public string? CustomerName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.SalesOrderStatus OrderStatus { get; set; } +} + +public record GetSalesOrderListQuery() : IRequest>; + +public class GetSalesOrderListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetSalesOrderListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSalesOrderListQuery request, CancellationToken cancellationToken) + { + return await _context.SalesOrder + .AsNoTracking() + .Include(x => x.Customer) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetSalesOrderListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + OrderDate = x.OrderDate, + CustomerName = x.Customer!.Name, + AfterTaxAmount = x.AfterTaxAmount, + OrderStatus = x.OrderStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/GetSalesOrderLookupHandler.cs b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderLookupHandler.cs new file mode 100644 index 0000000..9355f90 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/GetSalesOrderLookupHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class SalesOrderLookupResponse +{ + public List Customers { get; set; } = new(); + public List Taxes { get; set; } = new(); + public List Products { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record GetSalesOrderLookupQuery() : IRequest; + +public class GetSalesOrderLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesOrderLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesOrderLookupQuery request, CancellationToken cancellationToken) + { + var response = new SalesOrderLookupResponse(); + + response.Customers = await _context.Customer.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Taxes = await _context.Tax.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(SalesOrderStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderHandler.cs b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderHandler.cs new file mode 100644 index 0000000..c3bdd6a --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class UpdateSalesOrderRequest +{ + public string? Id { get; set; } + public DateTime? OrderDate { get; set; } + public Data.Enums.SalesOrderStatus OrderStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public string? TaxId { get; set; } + public decimal? BeforeTaxAmount { get; set; } + public decimal? TaxAmount { get; set; } + public decimal? AfterTaxAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateSalesOrderCommand(UpdateSalesOrderRequest Data) : IRequest; + +public class UpdateSalesOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesOrderCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesOrder + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.OrderDate = request.Data.OrderDate; + entity.OrderStatus = request.Data.OrderStatus; + entity.Description = request.Data.Description; + entity.CustomerId = request.Data.CustomerId; + entity.TaxId = request.Data.TaxId; + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.Id)) + { + SalesOrderHelper.Recalculate(_context, entity.Id); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderItemHandler.cs b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderItemHandler.cs new file mode 100644 index 0000000..c9ed67d --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderItemHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class UpdateSalesOrderItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record UpdateSalesOrderItemCommand(UpdateSalesOrderItemRequest Data) : IRequest; + +public class UpdateSalesOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesOrderItem + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.Summary = request.Data.Summary; + entity.UnitPrice = request.Data.UnitPrice ?? 0; + entity.Quantity = request.Data.Quantity ?? 0; + entity.Total = (request.Data.UnitPrice ?? 0) * (decimal)(request.Data.Quantity ?? 0); + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.SalesOrderId)) + { + SalesOrderHelper.Recalculate(_context, entity.SalesOrderId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderValidator.cs b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderValidator.cs new file mode 100644 index 0000000..95e48e3 --- /dev/null +++ b/Features/Sales/SalesOrder/Cqrs/UpdateSalesOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesOrder.Cqrs; + +public class UpdateSalesOrderValidator : AbstractValidator +{ + public UpdateSalesOrderValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.CustomerId).NotEmpty().WithMessage("Customer is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/SalesOrderEndpoint.cs b/Features/Sales/SalesOrder/SalesOrderEndpoint.cs new file mode 100644 index 0000000..d3b36ea --- /dev/null +++ b/Features/Sales/SalesOrder/SalesOrderEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.SalesOrder.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.SalesOrder; + +public static class SalesOrderEndpoint +{ + public static void MapSalesOrderEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-order").WithTags("SalesOrders") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesOrderListQuery()); + return result.ToApiResponse("Sales order list retrieved successfully"); + }) + .WithName("GetSalesOrderList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesOrderByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Sales order detail retrieved successfully" + : $"Sales order with ID {id} not found"); + }) + .WithName("GetSalesOrderById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesOrderLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetSalesOrderLookup"); + + group.MapPost("/", async (CreateSalesOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesOrderCommand(request)); + return result.ToApiResponse("Sales order has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSalesOrder"); + + group.MapPost("/update", async (UpdateSalesOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesOrderCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Sales order not found."); + } + return result.ToApiResponse("Sales order has been updated successfully"); + }) + .WithName("UpdateSalesOrder"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesOrderByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Sales order not found."); + } + return true.ToApiResponse("Sales order has been deleted successfully"); + }) + .WithName("DeleteSalesOrderById"); + + group.MapPost("/sales-order-item", async (CreateSalesOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesOrderItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateSalesOrderItem"); + + group.MapPost("/sales-order-item/update", async (UpdateSalesOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesOrderItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateSalesOrderItem"); + + group.MapPost("/sales-order-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesOrderItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteSalesOrderItem"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/SalesOrderPdfGenerator.cs b/Features/Sales/SalesOrder/SalesOrderPdfGenerator.cs new file mode 100644 index 0000000..56ba135 --- /dev/null +++ b/Features/Sales/SalesOrder/SalesOrderPdfGenerator.cs @@ -0,0 +1,152 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.SalesOrder.Cqrs; + +namespace Indotalent.Features.Sales.SalesOrder; + +public class SalesOrderPdfGenerator +{ + public static byte[] Generate(GetSalesOrderByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(1.5f, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().Row(row => + { + row.RelativeItem().Column(col => + { + col.Item().Text("SALES ORDER") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text(data.CompanyName).FontSize(11).ExtraBold(); + col.Item().Text($"{data.CompanyStreet}").FontColor(textSlate); + col.Item().Text($"{data.CompanyCity}, {data.CompanyState} {data.CompanyZipCode}").FontColor(textSlate); + col.Item().Text($"Tel: {data.CompanyPhone} | Email: {data.CompanyEmail}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("FROM (COMPANY)").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.CompanyName).Bold(); + c.Item().Text(data.CompanyStreet); + c.Item().Text($"{data.CompanyCity}, {data.CompanyState} {data.CompanyZipCode}"); + c.Item().Text($"Tel: {data.CompanyPhone}"); + c.Item().Text($"Email: {data.CompanyEmail}"); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("TO (CUSTOMER)").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.CustomerName).Bold(); + c.Item().Text(data.CustomerStreet); + c.Item().Text($"{data.CustomerCity}, {data.CustomerState} {data.CustomerZipCode}"); + c.Item().Text($"Tel: {data.CustomerPhone}"); + c.Item().Text($"Email: {data.CustomerEmail}"); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(25); + columns.RelativeColumn(4); + columns.RelativeColumn(2); + columns.RelativeColumn(1.5f); + columns.RelativeColumn(2.5f); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Description"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Unit Price"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text($"Total ({data.CurrencySymbol})"); + + static IContainer HeaderStyle(IContainer container) + { + return container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)) + .PaddingVertical(5).PaddingHorizontal(5).Background("#0D47A1"); + } + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Column(c => { + c.Item().Text(item.ProductName).Bold(); + if (!string.IsNullOrEmpty(item.Summary)) c.Item().Text(item.Summary).FontSize(8).FontColor(textSlate); + }); + table.Cell().Element(CellStyle).AlignRight().Text(item.UnitPrice?.ToString("N2")); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Quantity?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.Total?.ToString("N2")); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Notes:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + c.Item().PaddingTop(10).Text($"Date: {data.OrderDate?.ToString("dd MMM yyyy")}").FontSize(9); + c.Item().Text($"Status: {data.OrderStatus.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryBlue); + }); + row.ConstantItem(200).Column(c => + { + c.Item().Row(r => { + r.RelativeItem().Text("Sub Total"); + r.RelativeItem().AlignRight().Text(data.BeforeTaxAmount?.ToString("N2")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Tax Amount"); + r.RelativeItem().AlignRight().Text(data.TaxAmount?.ToString("N2")); + }); + c.Item().PaddingTop(5).BorderTop(1).Row(r => { + r.RelativeItem().Text($"TOTAL ({data.CurrencySymbol})").Bold(); + r.RelativeItem().AlignRight().Text(data.AfterTaxAmount?.ToString("N2")).ExtraBold().FontColor(primaryBlue); + }); + }); + }); + }); + + page.Footer().AlignCenter().Text(x => + { + x.Span("Page ").FontSize(8); + x.CurrentPageNumber().FontSize(8); + x.Span(" of ").FontSize(8); + x.TotalPages().FontSize(8); + }); + }); + }).GeneratePdf(); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesOrder/SalesOrderService.cs b/Features/Sales/SalesOrder/SalesOrderService.cs new file mode 100644 index 0000000..e270e32 --- /dev/null +++ b/Features/Sales/SalesOrder/SalesOrderService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.SalesOrder.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.SalesOrder; + +public class SalesOrderService : BaseService +{ + private readonly RestClient _client; + + public SalesOrderService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesOrderListAsync() + { + var request = new RestRequest("api/sales-order", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSalesOrderByIdAsync(string id) + { + var request = new RestRequest($"api/sales-order/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesOrderLookupAsync() + { + var request = new RestRequest("api/sales-order/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesOrderAsync(CreateSalesOrderRequest data) + { + var request = new RestRequest("api/sales-order", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesOrderAsync(UpdateSalesOrderRequest data) + { + var request = new RestRequest("api/sales-order/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesOrderByIdAsync(string id) + { + var request = new RestRequest($"api/sales-order/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateSalesOrderItemAsync(CreateSalesOrderItemRequest data) + { + var request = new RestRequest("api/sales-order/sales-order-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesOrderItemAsync(UpdateSalesOrderItemRequest data) + { + var request = new RestRequest("api/sales-order/sales-order-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesOrderItemAsync(string id) + { + var request = new RestRequest($"api/sales-order/sales-order-item/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesPage.razor b/Features/Sales/SalesPage.razor new file mode 100644 index 0000000..4fe40d1 --- /dev/null +++ b/Features/Sales/SalesPage.razor @@ -0,0 +1,112 @@ +@page "/sales" +@using Indotalent.Features.Sales.Invoice.Components +@using Indotalent.Features.Sales.InvoiceReport.Components +@using Indotalent.Features.Sales.PaymentReceive.Components +@using Indotalent.Features.Sales.PaymentReceiveReport.Components +@using Indotalent.Features.Sales.SalesOrder.Components +@using Indotalent.Features.Sales.SalesReport.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "order" }, + { 1, "invoice" }, + { 2, "payment" }, + { 3, "report-sales" }, + { 4, "report-invoice" }, + { 5, "report-payment" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + _activeTabIndex = match.Value != null ? match.Key : 0; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/sales?tab={tabName ?? "quotation"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReport/Components/SalesReportPage.razor b/Features/Sales/SalesReport/Components/SalesReportPage.razor new file mode 100644 index 0000000..0c6c218 --- /dev/null +++ b/Features/Sales/SalesReport/Components/SalesReportPage.razor @@ -0,0 +1,8 @@ +@page "/sales/sales-report" +@using Indotalent.Features.Sales.SalesReport.Components +@using MudBlazor + +<_SalesReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Sales/SalesReport/Components/_SalesReportDataTable.razor b/Features/Sales/SalesReport/Components/_SalesReportDataTable.razor new file mode 100644 index 0000000..4ddedd4 --- /dev/null +++ b/Features/Sales/SalesReport/Components/_SalesReportDataTable.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesReport +@using Indotalent.Features.Sales.SalesReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesReportService SalesReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Report + Detailed analysis of sales order items and revenue. +
+
+ + / + Sales + / + Sales Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Customer + + + Order No + + + Product Number + + + Product Name + + + Unit Price + + + Qty + + + Total + + + Order Date + + + + @context.CustomerName + @context.SalesOrderNumber + @context.ProductNumber + @context.ProductName + @context.UnitPrice?.ToString("N2") + @context.Quantity?.ToString("N2") + @context.Total?.ToString("N2") + @context.OrderDate?.ToString("yyyy-MM-dd") + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + private List _items = new(); + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 50; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var response = await SalesReportService.GetSalesReportListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SalesOrderNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("SalesReports"); + var currentRow = 1; + + string[] headers = { "Customer", "Order No", "Product Number", "Product Name", "Unit Price", "Quantity", "Total", "Order Date" }; + for (int i = 0; i < headers.Length; i++) + { + worksheet.Cell(currentRow, i + 1).Value = headers[i]; + } + + var headerRange = worksheet.Range(1, 1, 1, headers.Length); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.CustomerName; + worksheet.Cell(currentRow, 2).Value = item.SalesOrderNumber; + worksheet.Cell(currentRow, 3).Value = item.ProductNumber; + worksheet.Cell(currentRow, 4).Value = item.ProductName; + worksheet.Cell(currentRow, 5).Value = item.UnitPrice; + worksheet.Cell(currentRow, 6).Value = item.Quantity; + worksheet.Cell(currentRow, 7).Value = item.Total; + worksheet.Cell(currentRow, 8).Value = item.OrderDate?.ToString("yyyy-MM-dd"); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Sales_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + StateHasChanged(); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReport/Cqrs/GetSalesReportListHandler.cs b/Features/Sales/SalesReport/Cqrs/GetSalesReportListHandler.cs new file mode 100644 index 0000000..c655750 --- /dev/null +++ b/Features/Sales/SalesReport/Cqrs/GetSalesReportListHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReport.Cqrs; + +public record GetSalesReportListDto +{ + public string? Id { get; init; } + public string? SalesOrderId { get; init; } + public string? SalesOrderNumber { get; init; } + public string? CustomerName { get; init; } + public string? ProductId { get; init; } + public string? ProductName { get; init; } + public string? ProductNumber { get; init; } + public string? Summary { get; init; } + public decimal? UnitPrice { get; init; } + public double? Quantity { get; init; } + public decimal? Total { get; init; } + public DateTime? OrderDate { get; init; } +} + +public record GetSalesReportListQuery() : IRequest>; + +public class GetSalesReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSalesReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetSalesReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.SalesOrderItem + .AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(x => x!.Customer) + .Include(x => x.Product) + .OrderByDescending(x => x.SalesOrder!.OrderDate) + .Select(x => new GetSalesReportListDto + { + Id = x.Id, + SalesOrderId = x.SalesOrderId, + SalesOrderNumber = x.SalesOrder != null ? x.SalesOrder.AutoNumber : string.Empty, + CustomerName = x.SalesOrder!.Customer != null ? x.SalesOrder.Customer.Name : string.Empty, + ProductId = x.ProductId, + ProductName = x.Product != null ? x.Product.Name : string.Empty, + ProductNumber = x.Product != null ? x.Product.AutoNumber : string.Empty, + Summary = x.Summary, + UnitPrice = x.UnitPrice, + Quantity = x.Quantity, + Total = x.Total, + OrderDate = x.SalesOrder != null ? x.SalesOrder.OrderDate : null + }) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReport/SalesReportEndpoint.cs b/Features/Sales/SalesReport/SalesReportEndpoint.cs new file mode 100644 index 0000000..3d95723 --- /dev/null +++ b/Features/Sales/SalesReport/SalesReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.SalesReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.SalesReport; + +public static class SalesReportEndpoint +{ + public static void MapSalesReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-report").WithTags("SalesReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReportListQuery()); + return result.ToApiResponse("Sales report list retrieved successfully"); + }) + .WithName("GetSalesReportList"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReport/SalesReportService.cs b/Features/Sales/SalesReport/SalesReportService.cs new file mode 100644 index 0000000..c42a064 --- /dev/null +++ b/Features/Sales/SalesReport/SalesReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.SalesReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.SalesReport; + +public class SalesReportService : BaseService +{ + private readonly RestClient _client; + + public SalesReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesReportListAsync() + { + var request = new RestRequest("api/sales-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Serilogs/Database/Components/DatabasePage.razor b/Features/Serilogs/Database/Components/DatabasePage.razor new file mode 100644 index 0000000..2fca2f1 --- /dev/null +++ b/Features/Serilogs/Database/Components/DatabasePage.razor @@ -0,0 +1,404 @@ +@page "/serilogs/database" +@using Indotalent.Data.Entities +@using Indotalent.Infrastructure.Database +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.EntityFrameworkCore +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using Indotalent.Features.Serilogs.Database +@using Indotalent.Shared.Models +@using ClosedXML.Excel +@using System.IO +@inject SerilogsService SerilogsService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Database Logs + Monitor application events, errors, and system activities stored in MSSQL Server. +
+ +
+ + / + Serilogs + / + Database +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + + @if (_isClearing) + { + + } + else + { + Clear All + } + + + @if (_selectedLog != null) + { + View + Delete + + } +
+
+ + + + + Timestamp + Level + Message + Exception + + + + + + + @context.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss") + + + + @context.Level + + + + @context.Message + + + @if (!string.IsNullOrEmpty(context.Exception)) + { + + Has Error + } + else + { + - + } + + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(_totalCount == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, _totalCount) of @_totalCount + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + private GetSerilogLogsPagedListResponse? _selectedLog = null; + private List _pagedItems = new(); + private int _skip = 0; + private int _top = 5; + private int _totalCount = 0; + private int _totalPage = 0; + private int _currentPage => (_skip / _top) + 1; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isClearing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + var response = await SerilogsService.GetSerilogLogsODataAsync(_skip, _top, _searchString); + await Task.Delay(500); + if (response != null) + { + _pagedItems = response.Value ?? new(); + _totalCount = response.Count; + _totalPage = (int)Math.Ceiling((double)_totalCount / _top); + } + else + { + _pagedItems = new(); + _totalCount = 0; + _totalPage = 0; + } + _isRefreshing = false; + StateHasChanged(); + } + + private async Task HandleRefresh() + { + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private void HandleRowClick(TableRowClickEventArgs args) + { + _selectedLog = (_selectedLog?.Id == args.Item.Id) ? null : args.Item; + } + + private async Task OnSearchClick() + { + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private async Task HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") await OnSearchClick(); + } + + private async Task OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedLog = null; + await LoadData(); + } + } + + private async Task OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + var response = await SerilogsService.GetSerilogLogsODataAsync(0, GlobalConsts.ODataMaxTop, _searchString); + var dataToExport = response?.Value ?? new List(); + + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("DatabaseLogs"); + var currentRow = 1; + worksheet.Cell(currentRow, 1).Value = "Timestamp"; + worksheet.Cell(currentRow, 2).Value = "Level"; + worksheet.Cell(currentRow, 3).Value = "Message"; + worksheet.Cell(currentRow, 4).Value = "Exception"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in dataToExport) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss"); + worksheet.Cell(currentRow, 2).Value = item.Level; + worksheet.Cell(currentRow, 3).Value = item.Message; + worksheet.Cell(currentRow, 4).Value = item.Exception; + } + + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Database_Logs.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private async Task OnDeleteAll() + { + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, "ALL database logs" } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Confirmation", parameters); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + _isClearing = true; + StateHasChanged(); + if (await SerilogsService.DeleteSerilogLogsAllAsync()) + { + Snackbar.Add("All logs cleared.", Severity.Success); + _selectedLog = null; _skip = 0; await LoadData(); + } + _isClearing = false; + StateHasChanged(); + } + } + + private async Task OnDeleteSingle() + { + if (_selectedLog == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, $"Log ID {_selectedLog.Id}" } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Confirmation", parameters); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + if (await SerilogsService.DeleteSerilogLogsByIdAsync(_selectedLog.Id)) + { + Snackbar.Add("Log deleted.", Severity.Success); + _selectedLog = null; await LoadData(); + } + } + } + + private async Task OnViewDetail() + { + if (_selectedLog == null) return; + var detail = await SerilogsService.GetSerilogLogsByIdAsync(_selectedLog.Id); + if (detail != null) + { + var parameters = new DialogParameters<_SerilogDetailDialog> { { x => x.Log, detail } }; + await DialogService.ShowAsync<_SerilogDetailDialog>("Log Details", parameters, new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true }); + } + } + + private Color GetLevelColor(string? level) => level switch + { + "Information" or "Info" => Color.Info, + "Warning" => Color.Warning, + "Error" or "Fatal" => Color.Error, + _ => Color.Default + }; +} \ No newline at end of file diff --git a/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor b/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor new file mode 100644 index 0000000..969a128 --- /dev/null +++ b/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor @@ -0,0 +1,50 @@ +@using Indotalent.Features.Serilogs.Database +@using MudBlazor + + + + + + Log Detail [#@Log.Id] + + + + + + + Message + + @Log.Message + + + + @if (!string.IsNullOrEmpty(Log.Exception)) + { + + Exception / Stack Trace +
+ @Log.Exception +
+
+ } + + + Properties (JSON Context) +
+ @Log.Properties +
+
+
+
+
+ + Close + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public GetSerilogLogsByIdResponse Log { get; set; } = default!; + + private void Close() => MudDialog.Close(); +} \ No newline at end of file diff --git a/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs b/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs new file mode 100644 index 0000000..0eebf62 --- /dev/null +++ b/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs @@ -0,0 +1,28 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + + + + +public record DeleteSerilogLogsAllCommand() : IRequest; + +public class DeleteSerilogLogsHandler : + IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSerilogLogsHandler(AppDbContext context) + { + _context = context; + } + + + public async Task Handle(DeleteSerilogLogsAllCommand request, CancellationToken cancellationToken) + { + await _context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE SerilogLogs", cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs b/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs new file mode 100644 index 0000000..8504f58 --- /dev/null +++ b/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Serilogs.Database; + + + +public record DeleteSerilogLogsByIdCommand(int Id) : IRequest; + + +public class DeleteSerilogLogsByIdHandler : + IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSerilogLogsByIdHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteSerilogLogsByIdCommand request, CancellationToken cancellationToken) + { + var log = await _context.Set().FindAsync(request.Id); + if (log == null) return false; + + _context.Set().Remove(log); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } + +} \ No newline at end of file diff --git a/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs b/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs new file mode 100644 index 0000000..f66a8e6 --- /dev/null +++ b/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + +public record GetSerilogLogsByIdResponse( + int Id, + string? Message, + string? MessageTemplate, + string? Level, + DateTimeOffset TimeStamp, + string? Exception, + string? Properties); + +public record GetSerilogLogsByIdQuery(int Id) : IRequest; + +public class GetSerilogLogsByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSerilogLogsByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSerilogLogsByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetSerilogLogsByIdResponse( + x.Id, x.Message, x.MessageTemplate, x.Level, x.TimeStamp, x.Exception, x.Properties)) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs b/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs new file mode 100644 index 0000000..54a84c9 --- /dev/null +++ b/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Indotalent.Shared.Models; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + +public record GetSerilogLogsPagedListResponse( + int Id, + string? Message, + string? Level, + DateTimeOffset TimeStamp, + string? Exception) +{ + public GetSerilogLogsPagedListResponse() : this(0, null, null, DateTimeOffset.Now, null) { } +} + +public record GetSerilogLogsPagedListQuery(ApiRequest Request) : IRequest>; + +public class GetSerilogLogsPagedListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSerilogLogsPagedListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetSerilogLogsPagedListQuery query, CancellationToken cancellationToken) + { + var r = query.Request; + var queryable = _context.Set().AsNoTracking(); + + if (!string.IsNullOrEmpty(r.SearchTerm)) + { + queryable = queryable.Where(x => x.Message.Contains(r.SearchTerm) || x.Level.Contains(r.SearchTerm)); + } + + var pagedList = await queryable + .OrderByDescending(x => x.TimeStamp) + .Select(x => new GetSerilogLogsPagedListResponse( + x.Id, + x.Message, + x.Level, + x.TimeStamp, + x.Exception)) + .ToPagedListAsync(r.Skip, r.Top); + + return pagedList; + } +} \ No newline at end of file diff --git a/Features/Serilogs/File/Components/FilePage.razor b/Features/Serilogs/File/Components/FilePage.razor new file mode 100644 index 0000000..57bcc31 --- /dev/null +++ b/Features/Serilogs/File/Components/FilePage.razor @@ -0,0 +1,348 @@ +@page "/serilogs/file" +@using System.IO +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Hosting +@using Microsoft.JSInterop +@using MudBlazor +@using System.Linq +@inject IWebHostEnvironment Env +@inject IJSRuntime JS +@inject IConfiguration Configuration +@inject ISnackbar Snackbar + + +
+ File Logs + Browse and download log files stored on the local server storage. +
+ +
+ + / + Serilogs + / + File +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedFile != null) + { + + @if (_isDownloading) + { + + Downloading... + } + else + { + Download File + } + + + } +
+
+ + + + + + File Name + + + Last Modified + + + Size + + + + + + + +
+ + @context.FileName +
+
+ + @context.LastModified.ToString("yyyy-MM-dd HH:mm:ss") + + + @context.FileSize + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1) - @Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + +@code { + private List _allLogFiles = new(); + private LogFileInfo? _selectedFile; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isDownloading = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private int _currentPage => (_skip / _top) + 1; + + public class LogFileInfo + { + public string FileName { get; set; } = ""; + public string FullPath { get; set; } = ""; + public DateTime LastModified { get; set; } + public string FileSize { get; set; } = ""; + public long SizeInBytes { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await LoadFilesAsync(); + } + + private async Task HandleRefresh() + { + _selectedFile = null; + _skip = 0; + StateHasChanged(); + await LoadFilesAsync(); + StateHasChanged(); + } + + private async Task LoadFilesAsync() + { + _isRefreshing = true; + _allLogFiles.Clear(); + string configPath = Configuration["LoggerSettings:File:Path"] ?? "wwwroot/xlogs/app-log-.txt"; + string? directoryPath = Path.GetDirectoryName(configPath); + if (string.IsNullOrEmpty(directoryPath)) directoryPath = "wwwroot/xlogs"; + string fullDirectoryPath = Path.Combine(Env.ContentRootPath, directoryPath); + + if (Directory.Exists(fullDirectoryPath)) + { + var files = new DirectoryInfo(fullDirectoryPath).GetFiles("*.txt"); + foreach (var file in files.OrderByDescending(f => f.LastWriteTime)) + { + _allLogFiles.Add(new LogFileInfo + { + FileName = file.Name, + FullPath = file.FullName, + LastModified = file.LastWriteTime, + SizeInBytes = file.Length, + FileSize = GetFriendlyFileSize(file.Length) + }); + } + await Task.Delay(500); + } + _isRefreshing = false; + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _allLogFiles; + return _allLogFiles.Where(x => x.FileName.Contains(_searchString, StringComparison.OrdinalIgnoreCase)); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedFile = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedFile = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedFile = null; + StateHasChanged(); + } + + private void HandleRowClick(TableRowClickEventArgs args) + { + _selectedFile = (_selectedFile?.FullPath == args.Item.FullPath) ? null : args.Item; + } + + private async Task DownloadFile() + { + if (_selectedFile == null) return; + + _isDownloading = true; + StateHasChanged(); + + try + { + await Task.Delay(1000); + var bytes = await File.ReadAllBytesAsync(_selectedFile.FullPath); + using var stream = new MemoryStream(bytes); + using var streamRef = new DotNetStreamReference(stream); + + await JS.InvokeVoidAsync("downloadFileFromStream", _selectedFile.FileName, streamRef); + Snackbar.Add("Log file downloaded successfully", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Download failed: {ex.Message}", Severity.Error); + } + finally + { + _isDownloading = false; + StateHasChanged(); + } + } + + private string GetFriendlyFileSize(long bytes) + { + string[] suffix = { "B", "KB", "MB", "GB" }; + int i; + double dblSByte = bytes; + for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024) + { + dblSByte = bytes / 1024.0; + } + return $"{dblSByte:0.##} {suffix[i]}"; + } +} + + \ No newline at end of file diff --git a/Features/Serilogs/SerilogsEndpoint.cs b/Features/Serilogs/SerilogsEndpoint.cs new file mode 100644 index 0000000..6a8cd66 --- /dev/null +++ b/Features/Serilogs/SerilogsEndpoint.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Serilogs.Database; +using Indotalent.Shared.Models; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Serilogs; + +public static class SerilogsEndpoint +{ + public static void MapSerilogsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/serilog-logs").WithTags("Serilogs") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async ([AsParameters] ApiRequest request, IMediator mediator) => + { + var result = await mediator.Send(new GetSerilogLogsPagedListQuery(request)); + return result.ToApiResponse("Database logs retrieved successfully"); + }) + .WithName("GetSerilogLogsList") + .WithTags("Serilogs"); + + group.MapGet("/{id:int}", async (int id, IMediator mediator) => + { + var result = await mediator.Send(new GetSerilogLogsByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Log detail retrieved successfully" + : $"Log with ID {id} not found"); + }) + .WithName("GetSerilogLogById") + .WithTags("Serilogs"); + + group.MapPost("/delete/{id:int}", async (int id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSerilogLogsByIdCommand(id)); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The log data could not be found."); + } + + return true.ToApiResponse("Log entry has been deleted successfully"); + }) + .WithName("DeleteSerilogLogById") + .WithTags("Serilogs"); + + group.MapPost("/clear-all", async (IMediator mediator) => + { + await mediator.Send(new DeleteSerilogLogsAllCommand()); + + return true.ToApiResponse("All database logs have been cleared successfully"); + }) + .WithName("DeleteSerilogLogsAll") + .WithTags("Serilogs"); + } +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsODataController.cs b/Features/Serilogs/SerilogsODataController.cs new file mode 100644 index 0000000..98b706a --- /dev/null +++ b/Features/Serilogs/SerilogsODataController.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs; + +[Route("odata/SerilogLogs")] +public class SerilogsODataController : ODataController +{ + private readonly AppDbContext _context; + + public SerilogsODataController(AppDbContext context) + { + _context = context; + } + + [EnableQuery] + [HttpGet] + public IQueryable Get() + { + return _context.Set() + .OrderByDescending(x => x.TimeStamp) + .AsNoTracking(); + } + +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsPage.razor b/Features/Serilogs/SerilogsPage.razor new file mode 100644 index 0000000..67e6e48 --- /dev/null +++ b/Features/Serilogs/SerilogsPage.razor @@ -0,0 +1,88 @@ +@page "/serilogs" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.Admin)] +@using Indotalent.Features.Serilogs.Database +@using Indotalent.Features.Serilogs.Database.Components +@using Indotalent.Features.Serilogs.File +@using Indotalent.Features.Serilogs.File.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "database" => 0, + "file" => 1, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "database", + 1 => "file", + _ => "database" + }; + + NavigationManager.NavigateTo($"/serilogs?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsService.cs b/Features/Serilogs/SerilogsService.cs new file mode 100644 index 0000000..6073ef1 --- /dev/null +++ b/Features/Serilogs/SerilogsService.cs @@ -0,0 +1,105 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Serilogs.Database; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.OData; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Serilogs; + +public class SerilogsService : BaseService +{ + public SerilogsService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + } + + public async Task>?> GetSerilogLogsPagedListAsync(ApiRequest apiRequest) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest("api/serilog-logs", Method.Get); + + request.AddObject(apiRequest); + + return await ExecuteWithResponseAsync>(client, request); + } + + public async Task DeleteSerilogLogsByIdAsync(int id) + { + var client = new RestClient(CreateClient()); + + var request = new RestRequest($"api/serilog-logs/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.IsSuccess ?? false; + } + + public async Task DeleteSerilogLogsAllAsync() + { + var client = new RestClient(CreateClient()); + + var request = new RestRequest("api/serilog-logs/clear-all", Method.Post); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.IsSuccess ?? false; + } + + public async Task GetSerilogLogsByIdAsync(int id) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest($"api/serilog-logs/{id}", Method.Get); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.Value; + } + + public async Task?> GetSerilogLogsODataAsync(int skip, int top, string? searchTerm) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest("odata/SerilogLogs", Method.Get); + + request.AddHeader("Accept", "application/json"); + + request.AddQueryParameter("$skip", skip.ToString()); + request.AddQueryParameter("$top", top.ToString()); + request.AddQueryParameter("$count", "true"); + request.AddQueryParameter("$orderby", "TimeStamp desc"); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + string filter = $"contains(Message,'{searchTerm}') or contains(Level,'{searchTerm}')"; + request.AddQueryParameter("$filter", filter); + } + + var response = await client.ExecuteAsync(request); + + if (response.IsSuccessful && !string.IsNullOrWhiteSpace(response.Content)) + { + var data = System.Text.Json.JsonSerializer.Deserialize>(response.Content, new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (data != null) + { + return new PagedList(data.Value, data.Count, skip, top); + } + } + + return null; + } + + +} + diff --git a/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs b/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs new file mode 100644 index 0000000..7a6a3d7 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs @@ -0,0 +1,45 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.AutoNumberSequence.Cqrs; +using MediatR; + +namespace Indotalent.Features.Setting.AutoNumberSequence; + +public static class AutoNumberSequenceEndpoint +{ + public static void MapAutoNumberSequenceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/auto-number-sequences").WithTags("Auto Number Sequences"); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetAutoNumberSequenceListQuery()); + return result.ToApiResponse("Data auto number sequences retrieved successfully"); + }) + .WithName("GetAutoNumberSequenceList") + .WithTags("Auto Number Sequences"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetAutoNumberSequenceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Sequence detail retrieved successfully" + : $"Sequence with ID {id} not found"); + }) + .WithName("GetAutoNumberSequenceById") + .WithTags("Auto Number Sequences"); + + group.MapPost("/update", async (UpdateAutoNumberSequenceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateAutoNumberSequenceCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The sequence data could not be found."); + } + + return result.ToApiResponse("Sequence has been updated successfully"); + }) + .WithName("UpdateAutoNumberSequence") + .WithTags("Auto Number Sequences"); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs b/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs new file mode 100644 index 0000000..47cac81 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs @@ -0,0 +1,39 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.AutoNumberSequence.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.AutoNumberSequence; + +public class AutoNumberSequenceService : BaseService +{ + private readonly RestClient _client; + + public AutoNumberSequenceService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetAutoNumberSequenceListAsync() + { + var request = new RestRequest("api/auto-number-sequences", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> UpdateAutoNumberSequenceAsync(UpdateAutoNumberSequenceRequest data) + { + var request = new RestRequest("api/auto-number-sequences/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor b/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor new file mode 100644 index 0000000..5c1f601 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor @@ -0,0 +1,43 @@ +@page "/setting/auto-number-sequence" +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using MudBlazor + +@if (_isShowingForm) +{ + <_AutoNumberSequenceForm ExistingData="_selectedData" + ReadOnly="_isReadOnly" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_AutoNumberSequenceDataTable OnEdit="(item) => ShowForm(item, false)" + OnView="(item) => ShowForm(item, true)" /> +} + +@code { + private bool _isShowingForm = false; + private bool _isReadOnly = false; + private GetAutoNumberSequenceListResponse? _selectedData; + + private void ShowForm(GetAutoNumberSequenceListResponse data, bool readOnly) + { + _selectedData = data; + _isReadOnly = readOnly; + _isShowingForm = true; + } + + private void BackToTable() + { + _isShowingForm = false; + _selectedData = null; + _isReadOnly = false; + } + + private void HandleSuccess() + { + _isShowingForm = false; + _selectedData = null; + _isReadOnly = false; + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor new file mode 100644 index 0000000..c0c6a9a --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor @@ -0,0 +1,354 @@ +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using Indotalent.Shared.Models +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using ClosedXML.Excel +@using System.IO +@inject AutoNumberSequenceService AutoNumberSequenceService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Auto Number Sequences + Manage numbering rules, prefixes, and sequence counters for various entities. +
+
+ + / + Settings + / + Auto Number +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + + } +
+
+ + + + + + Entity Name + + + Prefix + + Year + Month + Current Seq + Suffix + Padding + + + + + + + @context.EntityName + + + + @(string.IsNullOrEmpty(context.PrefixTemplate) ? "-" : context.PrefixTemplate) + + + @context.Year + @(context.Month?.ToString("D2") ?? "-") + + + @context.CurrentSequence + + + + + @(string.IsNullOrEmpty(context.SuffixTemplate) ? "-" : context.SuffixTemplate) + + + @context.PaddingLength + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetAutoNumberSequenceListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await AutoNumberSequenceService.GetAutoNumberSequenceListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.EntityName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PrefixTemplate?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SuffixTemplate?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("AutoNumberSequences"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Entity Name"; + worksheet.Cell(currentRow, 2).Value = "Prefix"; + worksheet.Cell(currentRow, 3).Value = "Year"; + worksheet.Cell(currentRow, 4).Value = "Month"; + worksheet.Cell(currentRow, 5).Value = "Current Seq"; + worksheet.Cell(currentRow, 6).Value = "Suffix"; + worksheet.Cell(currentRow, 7).Value = "Padding"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EntityName; + worksheet.Cell(currentRow, 2).Value = item.PrefixTemplate; + worksheet.Cell(currentRow, 3).Value = item.Year; + worksheet.Cell(currentRow, 4).Value = item.Month; + worksheet.Cell(currentRow, 5).Value = item.CurrentSequence; + worksheet.Cell(currentRow, 6).Value = item.SuffixTemplate; + worksheet.Cell(currentRow, 7).Value = item.PaddingLength; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Auto_Number_Sequences.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem != null) await OnEdit.InvokeAsync(_selectedItem); + } + + private async Task InvokeView() + { + if (_selectedItem != null) await OnView.InvokeAsync(_selectedItem); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor new file mode 100644 index 0000000..9cf6f0b --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor @@ -0,0 +1,155 @@ +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject AutoNumberSequenceService AutoNumberSequenceService +@inject ISnackbar Snackbar + + +
+ +
+ @GetTitle() + Adjust the numbering rules and template formats for system entities. +
+
+
+ + + + + + Entity Name + + + + + Prefix Template + + + + + Suffix Template + + + + @if (ReadOnly) + { + + Current Sequence + + + + Padding Length + + + + Period + + + } + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + + @if (!ReadOnly) + { + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public GetAutoNumberSequenceListResponse? ExistingData { get; set; } + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateAutoNumberSequenceValidator _validator = new(); + private UpdateAutoNumberSequenceRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + if (ExistingData != null) + { + _model = new UpdateAutoNumberSequenceRequest + { + Id = ExistingData.Id, + EntityName = ExistingData.EntityName, + PrefixTemplate = ExistingData.PrefixTemplate, + SuffixTemplate = ExistingData.SuffixTemplate + }; + } + } + + private string GetTitle() + { + if (ReadOnly) return "Auto Number Details"; + return "Edit Auto Number Template"; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await AutoNumberSequenceService.UpdateAutoNumberSequenceAsync(_model); + + await Task.Delay(500); + + if (response?.IsSuccess ?? false) + { + Snackbar.Add("Template updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"System Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs new file mode 100644 index 0000000..93ef761 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs @@ -0,0 +1,28 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public record GetAutoNumberSequenceByIdResponse( + string Id, string EntityName, int? Year, int? Month, + long CurrentSequence, string PrefixTemplate, string SuffixTemplate, int PaddingLength); + +public record GetAutoNumberSequenceByIdQuery(string Id) : IRequest; + +public class GetAutoNumberSequenceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetAutoNumberSequenceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetAutoNumberSequenceByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetAutoNumberSequenceByIdResponse( + x.Id, x.EntityName ?? "", x.Year, x.Month, + x.CurrentSequence ?? 0, x.PrefixTemplate ?? "", x.SuffixTemplate ?? "", x.PaddingLength ?? 0)) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs new file mode 100644 index 0000000..0bfe103 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class GetAutoNumberSequenceListResponse +{ + public string? Id { get; set; } + public string? EntityName { get; set; } + public int? Year { get; set; } + public int? Month { get; set; } + public long? CurrentSequence { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } + public int? PaddingLength { get; set; } +} + +public record GetAutoNumberSequenceListQuery() : IRequest>; + +public class GetAutoNumberSequenceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetAutoNumberSequenceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetAutoNumberSequenceListQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .AsNoTracking() + .OrderBy(x => x.EntityName) + .Select(x => new GetAutoNumberSequenceListResponse + { + Id = x.Id, + EntityName = x.EntityName, + Year = x.Year, + Month = x.Month, + CurrentSequence = x.CurrentSequence, + PrefixTemplate = x.PrefixTemplate, + SuffixTemplate = x.SuffixTemplate, + PaddingLength = x.PaddingLength + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs new file mode 100644 index 0000000..bd35026 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class UpdateAutoNumberSequenceRequest +{ + public string? Id { get; set; } + public string? EntityName { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } +} + +public class UpdateAutoNumberSequenceResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateAutoNumberSequenceCommand(UpdateAutoNumberSequenceRequest Data) : IRequest; + +public class UpdateAutoNumberSequenceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateAutoNumberSequenceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateAutoNumberSequenceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Set() + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateAutoNumberSequenceResponse { Id = request.Data.Id, Success = false }; + + entity.PrefixTemplate = request.Data.PrefixTemplate; + entity.SuffixTemplate = request.Data.SuffixTemplate; + + await _context.SaveChangesAsync(cancellationToken); + return new UpdateAutoNumberSequenceResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs new file mode 100644 index 0000000..c389e21 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class UpdateAutoNumberSequenceValidator : AbstractValidator +{ + public UpdateAutoNumberSequenceValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required"); + RuleFor(x => x.PrefixTemplate).MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.SuffixTemplate).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/CompanyEndpoint.cs b/Features/Setting/Company/CompanyEndpoint.cs new file mode 100644 index 0000000..4867de5 --- /dev/null +++ b/Features/Setting/Company/CompanyEndpoint.cs @@ -0,0 +1,46 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Company.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Company; + +public static class CompanyEndpoint +{ + public static void MapCompanyEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/company").WithTags("Companies") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCompanyListQuery()); + return result.ToApiResponse("Company list retrieved successfully"); + }); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCompanyByIdQuery(id)); + return result.ToApiResponse(result is not null ? "Company retrieved" : "Not found"); + }); + + group.MapPost("/", async (CreateCompanyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCompanyCommand(request)); + return result.ToApiResponse("Company created successfully", StatusCodes.Status201Created); + }); + + group.MapPost("/update", async (UpdateCompanyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCompanyCommand(request)); + return result.Success ? result.ToApiResponse("Company updated") : result.ToApiResponse("Failed"); + }); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCompanyByIdCommand(new DeleteCompanyByIdRequest(id))); + return result.ToApiResponse("Company deleted"); + }); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/CompanyService.cs b/Features/Setting/Company/CompanyService.cs new file mode 100644 index 0000000..ed7d3e2 --- /dev/null +++ b/Features/Setting/Company/CompanyService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Company.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Company; + +public class CompanyService : BaseService +{ + private readonly RestClient _client; + + public CompanyService(IHttpClientFactory cf, NavigationManager nav, ISnackbar snack, ICurrentUserService cus, TokenProvider tp) + : base(cf, nav, snack, cus, tp) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCompanyListAsync() + { + var request = new RestRequest("api/company", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCompanyByIdAsync(string id) + { + var request = new RestRequest($"api/company/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCompanyAsync(CreateCompanyRequest data) + { + var request = new RestRequest("api/company", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateCompanyAsync(UpdateCompanyRequest data) + { + var request = new RestRequest("api/company/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCompanyByIdAsync(string id) + { + var request = new RestRequest($"api/company/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/CompanyPage.razor b/Features/Setting/Company/Components/CompanyPage.razor new file mode 100644 index 0000000..00c914f --- /dev/null +++ b/Features/Setting/Company/Components/CompanyPage.razor @@ -0,0 +1,52 @@ +@page "/setting/company" +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CompanyCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CompanyUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_CompanyDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCompanyRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateCompanyRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyCreateForm.razor b/Features/Setting/Company/Components/_CompanyCreateForm.razor new file mode 100644 index 0000000..d06eb37 --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyCreateForm.razor @@ -0,0 +1,250 @@ +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CompanyService CompanyService +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Company + Configure organization identity and legal details. +
+
+
+ + + + + + + Company Profile + Basic identity and branding of your organization. + + + + + +
+ Company Name + +
+
+ +
+ Default Currency + + @foreach (var curr in _currencies) + { + +
+ @curr.Code + (@curr.Symbol) +
+
+ } +
+
+
+
+
+ Company Description + +
+ + + Set as Default Company + + + + + + Tax ID (NPWP) + + + + Business License (NIB) + + + +
+
+
+ + + + + + Head Office Address + Main office location and contact details. + + + +
+ Street Address + +
+ + + City + + + + State/Province + + + + ZIP Code + + + + + + Phone + + + + Email + + + +
+
+
+ + + + + + Social Media Presence + External links for recruitment branding. + + + +
+ LinkedIn + +
+
+ X (Twitter) + +
+
+ Facebook + +
+
+ Instagram + +
+
+ TikTok + +
+
+
+
+ + + + + + Other Information + Additional company metadata. + + + +
+ Other Information 1 + +
+
+ Other Information 2 + +
+
+ Other Information 3 + +
+
+
+
+ +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Company + } + +
+
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCompanyValidator _validator = new(); + private CreateCompanyRequest _model = new(); + private List _currencies = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await CurrencyService.GetCurrencyListAsync(); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CompanyService.CreateCompanyAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Company created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyDataTable.razor b/Features/Setting/Company/Components/_CompanyDataTable.razor new file mode 100644 index 0000000..aab5738 --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyDataTable.razor @@ -0,0 +1,437 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CompanyService CompanyService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Company Management + Manage organization profile, legal entities, and office locations. +
+
+ + / + Settings + / + Company +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCompany != null) + { + View + Edit + Remove + + } + else + { + + Add New Company + + } +
+
+ + + + + + Company Name + + + Email + + + Phone + + + City + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name + @if (context.IsDefault) + { + DEFAULT + } +
+
+ @context.Email + @context.Phone + @context.City +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _companies = new(); + private GetCompanyListResponse? _selectedCompany; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 10; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCompany = null; + StateHasChanged(); + try + { + var response = await CompanyService.GetCompanyListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _companies = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _companies; + return _companies.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Email?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.City?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Phone?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedCompany = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Companies"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Company Name"; + worksheet.Cell(currentRow, 2).Value = "Email"; + worksheet.Cell(currentRow, 3).Value = "Phone"; + worksheet.Cell(currentRow, 4).Value = "City"; + worksheet.Cell(currentRow, 5).Value = "Is Default"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Email; + worksheet.Cell(currentRow, 3).Value = item.Phone; + worksheet.Cell(currentRow, 4).Value = item.City; + worksheet.Cell(currentRow, 5).Value = item.IsDefault ? "Yes" : "No"; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Registered_Companies.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedCompany = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedCompany = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedCompany == null) return; + var request = await MapToUpdateRequest(_selectedCompany.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedCompany == null) return; + var request = await MapToUpdateRequest(_selectedCompany.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await CompanyService.GetCompanyByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateCompanyRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + IsDefault = detail.IsDefault, + CurrencyId = detail.CurrencyId, + TaxIdentification = detail.TaxIdentification, + BusinessLicense = detail.BusinessLicense, + CompanyLogo = detail.CompanyLogo, + StreetAddress = detail.StreetAddress, + City = detail.City, + StateProvince = detail.StateProvince, + ZipCode = detail.ZipCode, + Phone = detail.Phone, + Email = detail.Email, + SocialMediaLinkedIn = detail.SocialMediaLinkedIn, + SocialMediaX = detail.SocialMediaX, + SocialMediaFacebook = detail.SocialMediaFacebook, + SocialMediaInstagram = detail.SocialMediaInstagram, + SocialMediaTikTok = detail.SocialMediaTikTok, + OtherInformation1 = detail.OtherInformation1, + OtherInformation2 = detail.OtherInformation2, + OtherInformation3 = detail.OtherInformation3, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCompany == null) return; + + if (_selectedCompany.IsDefault) + { + Snackbar.Add("Cannot delete the default company. Please set another company as default first.", Severity.Warning); + return; + } + + if (_companies.Count <= 1) + { + Snackbar.Add("Cannot delete the last remaining company. At least one company must exist in the system.", Severity.Error); + return; + } + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCompany.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await CompanyService.DeleteCompanyByIdAsync(_selectedCompany.Id); + if (isSuccess) + { + _selectedCompany = null; + await LoadData(); + Snackbar.Add("Company deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyUpdateForm.razor b/Features/Setting/Company/Components/_CompanyUpdateForm.razor new file mode 100644 index 0000000..47c993c --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyUpdateForm.razor @@ -0,0 +1,317 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CompanyService CompanyService +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Company Details" : "Edit Company") + @(ReadOnly ? "Viewing full organization profile." : "Modify existing company information.") +
+
+
+ + + + + + + Company Profile + Basic identity and branding of your organization. + + + + + +
+ Company Name + +
+
+ +
+ Default Currency + + @if (_currencies != null) + { + @foreach (var curr in _currencies) + { + +
+ @curr.Code + (@curr.Symbol) +
+
+ } + } +
+
+
+
+
+ Company Description + +
+ + + Primary Organization + + + + + + Tax ID (NPWP) + + + + Business License (NIB) + + + +
+
+
+ + + + + + Head Office Address + Main office location and contact details. + + + +
+ Street Address + +
+ + + City + + + + State/Province + + + + ZIP Code + + + + + + Phone + + + + Email + + + +
+
+
+ + + + + + Social Media Presence + External links for recruitment branding. + + + +
+ LinkedIn + +
+
+ X (Twitter) + +
+
+ Facebook + +
+
+ Instagram + +
+
+ TikTok + +
+
+
+
+ + + + + + Other Information + Additional company metadata. + + + +
+ Other Information 1 + +
+
+ Other Information 2 + +
+
+ Other Information 3 + +
+
+
+
+ + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+
+ +@code { + [Parameter] public UpdateCompanyRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateCompanyValidator _validator = new(); + private UpdateCompanyRequest _model = new(); + private List _currencies = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + _processing = true; + var response = await CurrencyService.GetCurrencyListAsync(); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new(); + } + + _model = new UpdateCompanyRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + IsDefault = Data.IsDefault, + CurrencyId = Data.CurrencyId, + TaxIdentification = Data.TaxIdentification, + BusinessLicense = Data.BusinessLicense, + StreetAddress = Data.StreetAddress, + City = Data.City, + StateProvince = Data.StateProvince, + ZipCode = Data.ZipCode, + Phone = Data.Phone, + Email = Data.Email, + SocialMediaLinkedIn = Data.SocialMediaLinkedIn, + SocialMediaX = Data.SocialMediaX, + SocialMediaFacebook = Data.SocialMediaFacebook, + SocialMediaInstagram = Data.SocialMediaInstagram, + SocialMediaTikTok = Data.SocialMediaTikTok, + OtherInformation1 = Data.OtherInformation1, + OtherInformation2 = Data.OtherInformation2, + OtherInformation3 = Data.OtherInformation3, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + _processing = false; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CompanyService.UpdateCompanyAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Company updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs b/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs new file mode 100644 index 0000000..f1b195f --- /dev/null +++ b/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs @@ -0,0 +1,107 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class CreateCompanyRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsDefault { get; set; } + public string? CurrencyId { get; set; } + public string? TaxIdentification { get; set; } + public string? BusinessLicense { get; set; } + public string? CompanyLogo { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaX { get; set; } + public string? SocialMediaFacebook { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaTikTok { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } +} + +public class CreateCompanyResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCompanyCommand(CreateCompanyRequest Data) : IRequest; + +public class CreateCompanyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCompanyHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCompanyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Company + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Company", request.Data.Name ?? string.Empty); + } + + if (request.Data.IsDefault) + { + var existingDefaults = await _context.Company + .Where(x => x.IsDefault) + .ToListAsync(cancellationToken); + + foreach (var comp in existingDefaults) + { + comp.IsDefault = false; + } + } + + var entityName = nameof(Data.Entities.Company); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Company + { + AutoNumber = autoNo, + Name = request.Data.Name ?? string.Empty, + Description = request.Data.Description ?? string.Empty, + IsDefault = request.Data.IsDefault, + CurrencyId = request.Data.CurrencyId ?? string.Empty, + TaxIdentification = request.Data.TaxIdentification ?? string.Empty, + BusinessLicense = request.Data.BusinessLicense ?? string.Empty, + StreetAddress = request.Data.StreetAddress ?? string.Empty, + City = request.Data.City ?? string.Empty, + StateProvince = request.Data.StateProvince ?? string.Empty, + ZipCode = request.Data.ZipCode ?? string.Empty, + Phone = request.Data.Phone ?? string.Empty, + Email = request.Data.Email ?? string.Empty, + SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty, + SocialMediaX = request.Data.SocialMediaX ?? string.Empty, + SocialMediaFacebook = request.Data.SocialMediaFacebook ?? string.Empty, + SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty, + SocialMediaTikTok = request.Data.SocialMediaTikTok ?? string.Empty, + OtherInformation1 = request.Data.OtherInformation1 ?? string.Empty, + OtherInformation2 = request.Data.OtherInformation2 ?? string.Empty, + OtherInformation3 = request.Data.OtherInformation3 ?? string.Empty + }; + + _context.Company.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCompanyResponse { Id = entity.Id, Name = entity.Name }; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs b/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs new file mode 100644 index 0000000..4fad946 --- /dev/null +++ b/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class CreateCompanyValidator : AbstractValidator +{ + public CreateCompanyValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Company Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CurrencyId) + .NotEmpty().WithMessage("Default Currency is required"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("Phone number is required"); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs b/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs new file mode 100644 index 0000000..de56766 --- /dev/null +++ b/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public record DeleteCompanyByIdRequest(string Id); +public record DeleteCompanyByIdCommand(DeleteCompanyByIdRequest Data) : IRequest; + +public class DeleteCompanyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCompanyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCompanyByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Company + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + if (entity.IsDefault) return false; + + var totalCount = await _context.Company.CountAsync(cancellationToken); + if (totalCount <= 1) + { + return false; + } + + _context.Company.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs b/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs new file mode 100644 index 0000000..52819fa --- /dev/null +++ b/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class GetCompanyByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsDefault { get; set; } + public string? CurrencyId { get; set; } + public string? TaxIdentification { get; set; } + public string? BusinessLicense { get; set; } + public string? CompanyLogo { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaX { get; set; } + public string? SocialMediaFacebook { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaTikTok { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCompanyByIdQuery(string Id) : IRequest; + +public class GetCompanyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCompanyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCompanyByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Company + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCompanyByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + IsDefault = x.IsDefault, + CurrencyId = x.CurrencyId, + TaxIdentification = x.TaxIdentification, + BusinessLicense = x.BusinessLicense, + CompanyLogo = x.CompanyLogo, + StreetAddress = x.StreetAddress, + City = x.City, + StateProvince = x.StateProvince, + ZipCode = x.ZipCode, + Phone = x.Phone, + Email = x.Email, + SocialMediaLinkedIn = x.SocialMediaLinkedIn, + SocialMediaX = x.SocialMediaX, + SocialMediaFacebook = x.SocialMediaFacebook, + SocialMediaInstagram = x.SocialMediaInstagram, + SocialMediaTikTok = x.SocialMediaTikTok, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs b/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs new file mode 100644 index 0000000..c5aee8c --- /dev/null +++ b/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class GetCompanyListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? City { get; set; } + public bool IsDefault { get; set; } +} + +public record GetCompanyListQuery() : IRequest>; + +public class GetCompanyListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCompanyListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCompanyListQuery request, CancellationToken cancellationToken) + { + return await _context.Company + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetCompanyListResponse + { + Id = x.Id, + Name = x.Name, + Email = x.Email, + Phone = x.Phone, + City = x.City, + IsDefault = x.IsDefault + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs b/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs new file mode 100644 index 0000000..b9dedd0 --- /dev/null +++ b/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs @@ -0,0 +1,104 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class UpdateCompanyRequest : CreateCompanyRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCompanyResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCompanyCommand(UpdateCompanyRequest Data) : IRequest; + +public class UpdateCompanyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCompanyHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCompanyCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Company + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateCompanyResponse { Id = request.Data.Id, Success = false }; + + var isNameExists = await _context.Company + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isNameExists) + { + throw new AlreadyExistsException("Company", request.Data.Name ?? string.Empty); + } + + if (entity.IsDefault && !request.Data.IsDefault) + { + var totalCount = await _context.Company.CountAsync(cancellationToken); + + if (totalCount <= 1) + { + request.Data.IsDefault = true; + throw new Exception("At least one company must be set as default."); + } + } + + if (request.Data.IsDefault) + { + var otherDefaults = await _context.Company + .Where(x => x.IsDefault && x.Id != entity.Id) + .ToListAsync(cancellationToken); + + foreach (var comp in otherDefaults) + { + comp.IsDefault = false; + } + } + else + { + var anyOtherDefault = await _context.Company + .AnyAsync(x => x.IsDefault && x.Id != entity.Id, cancellationToken); + + if (!anyOtherDefault) + { + request.Data.IsDefault = true; + } + } + + entity.Name = request.Data.Name ?? string.Empty; + entity.Description = request.Data.Description ?? string.Empty; + entity.IsDefault = request.Data.IsDefault; + entity.CurrencyId = request.Data.CurrencyId ?? string.Empty; + entity.TaxIdentification = request.Data.TaxIdentification ?? string.Empty; + entity.BusinessLicense = request.Data.BusinessLicense ?? string.Empty; + entity.StreetAddress = request.Data.StreetAddress ?? string.Empty; + entity.City = request.Data.City ?? string.Empty; + entity.StateProvince = request.Data.StateProvince ?? string.Empty; + entity.ZipCode = request.Data.ZipCode ?? string.Empty; + entity.Phone = request.Data.Phone ?? string.Empty; + entity.Email = request.Data.Email ?? string.Empty; + entity.SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty; + entity.SocialMediaX = request.Data.SocialMediaX ?? string.Empty; + entity.SocialMediaFacebook = request.Data.SocialMediaFacebook ?? string.Empty; + entity.SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty; + entity.SocialMediaTikTok = request.Data.SocialMediaTikTok ?? string.Empty; + entity.OtherInformation1 = request.Data.OtherInformation1 ?? string.Empty; + entity.OtherInformation2 = request.Data.OtherInformation2 ?? string.Empty; + entity.OtherInformation3 = request.Data.OtherInformation3 ?? string.Empty; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCompanyResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs b/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs new file mode 100644 index 0000000..e13815e --- /dev/null +++ b/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class UpdateCompanyValidator : AbstractValidator +{ + public UpdateCompanyValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.Name).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.CurrencyId).NotEmpty(); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/CurrencyPage.razor b/Features/Setting/Currency/Components/CurrencyPage.razor new file mode 100644 index 0000000..a9760c7 --- /dev/null +++ b/Features/Setting/Currency/Components/CurrencyPage.razor @@ -0,0 +1,52 @@ +@page "/setting/currency" +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CurrencyCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CurrencyUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_CurrencyDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCurrencyRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateCurrencyRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyCreateForm.razor b/Features/Setting/Currency/Components/_CurrencyCreateForm.razor new file mode 100644 index 0000000..c191c10 --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyCreateForm.razor @@ -0,0 +1,118 @@ +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Currency + Enter currency details and international formatting. +
+
+
+ + + + + + Currency Code + + + + Symbol + + + + Currency Name + + + + Country Owner + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Currency + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCurrencyValidator _validator = new(); + private CreateCurrencyRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CurrencyService.CreateCurrencyAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Currency created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyDataTable.razor b/Features/Setting/Currency/Components/_CurrencyDataTable.razor new file mode 100644 index 0000000..f5608b1 --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyDataTable.razor @@ -0,0 +1,400 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CurrencyService CurrencyService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Currency Management + Manage global currencies and exchange rates. +
+
+ + / + Settings + / + Currency +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCurrency != null) + { + View + Edit + Remove + + } + else + { + + Add New Currency + + } +
+
+ + + + + + Currency Name + + + Code + + Symbol + + Country Owner + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Code + @context.Symbol + @context.CountryOwner +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _currencies = new(); + private GetCurrencyListResponse? _selectedCurrency; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCurrency = null; + StateHasChanged(); + try + { + var response = await CurrencyService.GetCurrencyListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _currencies; + return _currencies.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CountryOwner?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedCurrency = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Currencies"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Currency Name"; + worksheet.Cell(currentRow, 2).Value = "Code"; + worksheet.Cell(currentRow, 3).Value = "Symbol"; + worksheet.Cell(currentRow, 4).Value = "Country Owner"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Code; + worksheet.Cell(currentRow, 3).Value = item.Symbol; + worksheet.Cell(currentRow, 4).Value = item.CountryOwner; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Currencies_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedCurrency = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedCurrency = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedCurrency == null) return; + var request = await MapToUpdateRequest(_selectedCurrency.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedCurrency == null) return; + var request = await MapToUpdateRequest(_selectedCurrency.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await CurrencyService.GetCurrencyByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateCurrencyRequest + { + Id = detail.Id, + Code = detail.Code, + Name = detail.Name, + Symbol = detail.Symbol, + CountryOwner = detail.CountryOwner, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCurrency == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCurrency.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await CurrencyService.DeleteCurrencyByIdAsync(_selectedCurrency.Id); + if (isSuccess) + { + _selectedCurrency = null; + await LoadData(); + Snackbar.Add("Currency deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor b/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor new file mode 100644 index 0000000..c293c11 --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor @@ -0,0 +1,170 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Currency Details" : "Edit Currency") + @(ReadOnly ? "Viewing international currency specification." : "Modify existing currency information.") +
+
+
+ + + + + + Currency Code + + + + Symbol + + + + Currency Name + + + + Country Owner + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateCurrencyRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateCurrencyValidator _validator = new(); + private UpdateCurrencyRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateCurrencyRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Symbol = Data.Symbol, + CountryOwner = Data.CountryOwner, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CurrencyService.UpdateCurrencyAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Currency updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs b/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs new file mode 100644 index 0000000..1f46ad7 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class CreateCurrencyRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } +} + +public class CreateCurrencyResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateCurrencyCommand(CreateCurrencyRequest Data) : IRequest; +public class CreateCurrencyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCurrencyHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCurrencyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Currency + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Currency", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Currency); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Currency + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Symbol = request.Data.Symbol, + CountryOwner = request.Data.CountryOwner, + Description = request.Data.Description + }; + + _context.Currency.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCurrencyResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs b/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs new file mode 100644 index 0000000..6c6f117 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class CreateCurrencyValidator : AbstractValidator +{ + public CreateCurrencyValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Currency Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Currency Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Symbol) + .NotEmpty().WithMessage("Symbol is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CountryOwner) + .NotEmpty().WithMessage("Country Owner is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs b/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs new file mode 100644 index 0000000..15e8b25 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public record DeleteCurrencyByIdRequest(string Id); + +public class DeleteCurrencyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCurrencyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCurrencyByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Currency + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Currency.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} + +public record DeleteCurrencyByIdCommand(DeleteCurrencyByIdRequest Data) : IRequest; \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs b/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs new file mode 100644 index 0000000..b85fb6f --- /dev/null +++ b/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class GetCurrencyByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCurrencyByIdQuery(string Id) : IRequest; + +public class GetCurrencyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCurrencyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCurrencyByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Currency + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCurrencyByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Symbol = x.Symbol, + CountryOwner = x.CountryOwner, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs b/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs new file mode 100644 index 0000000..40b86bc --- /dev/null +++ b/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class GetCurrencyListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCurrencyListQuery() : IRequest>; + +public class GetCurrencyListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCurrencyListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCurrencyListQuery request, CancellationToken cancellationToken) + { + return await _context.Currency + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetCurrencyListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Symbol = x.Symbol, + CountryOwner = x.CountryOwner, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs b/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs new file mode 100644 index 0000000..93ab29b --- /dev/null +++ b/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class UpdateCurrencyRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCurrencyResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCurrencyCommand(UpdateCurrencyRequest Data) : IRequest; + +public class UpdateCurrencyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCurrencyHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateCurrencyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Currency + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Currency", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Currency + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCurrencyResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Symbol = request.Data.Symbol; + entity.CountryOwner = request.Data.CountryOwner; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCurrencyResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs b/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs new file mode 100644 index 0000000..061e2ec --- /dev/null +++ b/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class UpdateCurrencyValidator : AbstractValidator +{ + public UpdateCurrencyValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Currency Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Currency Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Symbol) + .NotEmpty().WithMessage("Symbol is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CountryOwner) + .NotEmpty().WithMessage("Country Owner is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/CurrencyEndpoint.cs b/Features/Setting/Currency/CurrencyEndpoint.cs new file mode 100644 index 0000000..4625766 --- /dev/null +++ b/Features/Setting/Currency/CurrencyEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Currency.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Currency; + +public static class CurrencyEndpoint +{ + public static void MapCurrencyEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/currency").WithTags("Currencies") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCurrencyListQuery()); + + return result.ToApiResponse("Data currency retrieved successfully"); + }) + .WithName("GetCurrencyList") + .WithTags("Currencies"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCurrencyByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Currency detail retrieved successfully" + : $"Currency with ID {id} not found"); + }) + .WithName("GetCurrencyById") + .WithTags("Currencies"); + + group.MapPost("/", async (CreateCurrencyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCurrencyCommand(request)); + + return result.ToApiResponse("Currency has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCurrency") + .WithTags("Currencies"); + + group.MapPost("/update", async (UpdateCurrencyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCurrencyCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The currency data could not be found."); + } + + return result.ToApiResponse("Currency has been updated successfully"); + }) + .WithName("UpdateCurrency") + .WithTags("Currencies"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCurrencyByIdCommand(new DeleteCurrencyByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The currency data could not be found."); + } + + return true.ToApiResponse("Currency has been deleted successfully"); + }) + .WithName("DeleteCurrencyById") + .WithTags("Currencies"); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/CurrencyService.cs b/Features/Setting/Currency/CurrencyService.cs new file mode 100644 index 0000000..7de716a --- /dev/null +++ b/Features/Setting/Currency/CurrencyService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Currency.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Currency; + +public class CurrencyService : BaseService +{ + private readonly RestClient _client; + + public CurrencyService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCurrencyListAsync() + { + var request = new RestRequest("api/currency", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCurrencyByIdAsync(string id) + { + var request = new RestRequest($"api/currency/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCurrencyAsync(CreateCurrencyRequest data) + { + var request = new RestRequest("api/currency", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCurrencyByIdAsync(string id) + { + var request = new RestRequest($"api/currency/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCurrencyAsync(UpdateCurrencyRequest data) + { + var request = new RestRequest("api/currency/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Setting/SettingPage.razor b/Features/Setting/SettingPage.razor new file mode 100644 index 0000000..a622320 --- /dev/null +++ b/Features/Setting/SettingPage.razor @@ -0,0 +1,108 @@ +@page "/setting" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.Admin)] +@using Indotalent.Features.Setting.AutoNumberSequence.Components +@using Indotalent.Features.Setting.Company.Components +@using Indotalent.Features.Setting.Currency.Components +@using Indotalent.Features.Setting.SystemUser.Components +@using Indotalent.Features.Setting.Tax.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "company" => 0, + "user" => 1, + "currency" => 2, + "tax" => 3, + "autonumber" => 4, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "company", + 1 => "user", + 2 => "currency", + 3 => "tax", + 4 => "autonumber", + _ => "company" + }; + + NavigationManager.NavigateTo($"/setting?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/SystemUserPage.razor b/Features/Setting/SystemUser/Components/SystemUserPage.razor new file mode 100644 index 0000000..62442e2 --- /dev/null +++ b/Features/Setting/SystemUser/Components/SystemUserPage.razor @@ -0,0 +1,74 @@ +@page "/setting/system-user" +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SystemUserCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SystemUserUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangePassword) +{ + <_SystemUserChangePasswordForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangeRole) +{ + <_SystemUserChangeRoleForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangeAvatar) +{ + <_SystemUserChangeAvatarForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_SystemUserDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" + OnChangePassword="(item) => ShowChangePassword(item)" + OnManageRoles="(item) => ShowChangeRole(item)" + OnChangeAvatar="(item) => ShowChangeAvatar(item)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View, ChangePassword, ChangeRole, ChangeAvatar } + private ViewMode _currentView = ViewMode.Table; + private UpdateSystemUserRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateSystemUserRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + private void ShowChangePassword(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangePassword; + } + private void ShowChangeRole(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangeRole; + } + private void ShowChangeAvatar(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangeAvatar; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor new file mode 100644 index 0000000..5d5a4c9 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor @@ -0,0 +1,176 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using System.IO +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Update Profile Picture + Change avatar for user: @Data.FullName +
+
+
+ + + + + Preview Avatar + + @if (_isLoading) + { + + } + else if (!string.IsNullOrEmpty(_previewUrl)) + { +
+ +
+ } + else + { + + @Data.FullName.ToInitial() + + } + + + Allowed: JPG, JPEG, PNG. Max: 2MB. + +
+
+ + + + Upload New Image + + + + + @(_selectedFile == null ? "Click or Drag Image Here" : _selectedFile.Name) + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Update Avatar + } + +
+
+
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private IBrowserFile? _selectedFile; + private string? _previewUrl; + private bool _processing = false; + private bool _isLoading = false; + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(Data.AvatarFile)) + { + _isLoading = true; + try + { + var bytes = await SystemUserService.GetAvatarBlobAsync(Data.AvatarFile); + if (bytes != null && bytes.Length > 0) + { + _previewUrl = $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task HandleFileSelected(IBrowserFile file) + { + if (file == null) return; + + if (file.Size > 2 * 1024 * 1024) + { + Snackbar.Add("File too large. Max 2MB allowed.", Severity.Warning); + return; + } + + _selectedFile = file; + + using var stream = file.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + _previewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}"; + StateHasChanged(); + } + + private async Task SubmitAvatar() + { + if (_selectedFile == null) return; + + _processing = true; + try + { + using var stream = _selectedFile.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + var request = new ChangeAvatarRequest + { + UserId = Data.Id ?? string.Empty, + FileData = ms.ToArray(), + FileName = _selectedFile.Name + }; + + var response = await SystemUserService.ChangeAvatarAsync(request); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Avatar updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Upload failed: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor new file mode 100644 index 0000000..0ab204f --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor @@ -0,0 +1,167 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Security: Change Password + Manage password security for account: @Data.FullName +
+
+
+ + + + + Manual Password Overwrite + + + + + + New Password + + + + + Confirm New Password + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Set New Password + } + +
+
+
+
+ + + + Password Reset Link + + + + Instead of setting a password manually, you can send a secure reset link to the user's registered email: + + + @Data.Email + + + @if (_processingReset) + { + + Sending... + } + else + { + Send Forgot Password Email + } + + + + The user will receive an email with instructions to set their own password. + + + +
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private ChangePasswordRequest _model = new(); + private ChangePasswordValidator _validator = new(); + + private bool _processing = false; + private bool _processingReset = false; + + protected override void OnInitialized() + { + _model.UserId = Data.Id ?? string.Empty; + } + + private async Task SubmitManual() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await SystemUserService.ChangePasswordAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Password updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } + + private async Task SendResetLink() + { + _processingReset = true; + try + { + var success = await SystemUserService.SendResetLinkAsync(Data.Id ?? string.Empty); + await Task.Delay(500); + if (success) + { + Snackbar.Add("Reset link sent to user email", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processingReset = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor new file mode 100644 index 0000000..46d2258 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor @@ -0,0 +1,125 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Manage User Roles + Assign or revoke roles for: @Data.FullName (@Data.Email) +
+
+
+ + + Available System Roles + + + + @foreach (var role in ApplicationRoles.AllRoles) + { + + + + + @role + System Role + + + + } + + +
+ + Cancel + + + @if (_processing) + { + + Saving Roles... + } + else + { + Update Role Assignments + } + +
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private List _selectedRoles = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + if (Data.UserRoles != null) + { + _selectedRoles = new List(Data.UserRoles); + } + } + + private void OnRoleToggled(string role, bool isSelected) + { + if (isSelected) + { + if (!_selectedRoles.Contains(role)) _selectedRoles.Add(role); + } + else + { + _selectedRoles.Remove(role); + } + } + + private async Task Submit() + { + if (_selectedRoles.Count == 0) + { + Snackbar.Add("Please select at least one role", Severity.Warning); + return; + } + + _processing = true; + try + { + var request = new ChangeRoleRequest + { + UserId = Data.Id ?? string.Empty, + Roles = _selectedRoles + }; + + var success = await SystemUserService.ChangeRoleAsync(request); + await Task.Delay(500); + + if (success) + { + Snackbar.Add("User roles updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor b/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor new file mode 100644 index 0000000..4e4cfa0 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor @@ -0,0 +1,218 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor +@using Indotalent.Shared.Utils +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Add New User + Fill in the account details and security settings for the new user. +
+
+
+ + + + + + Primary Information + + + + + Full Name + + + + + Email Address (as Username) + + + + + Password + + + + + Phone Number + + + + + SSO Identifiers + + + + + Firebase ID + + + + Keycloak ID + + + + Azure ID + + + + AWS ID + + + + Other SSO ID 1 + + + + Other SSO ID 2 + + + + Other SSO ID 3 + + + + + Account Settings & Status + + + + + +
+ + General availability of the account in the system. +
+ +
+ + Mark email as verified without requiring user action. +
+ +
+ + Mark phone number as verified. +
+ +
+ + Enforce Two-Factor Authentication for this user. +
+
+
+ + + +
+ + Allow account to be locked after failed attempts. +
+ +
+ Manual Lockout Until + + Set this only if you want to pre-lock this new account. +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create User + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateSystemUserRequest _model = new() { IsActive = true, LockoutEnabled = true }; + private CreateSystemUserValidator _validator = new(); + private DateTime? _lockoutDate; + private bool _processing = false; + + private void OnEmailChanged(string value) + { + _model.Email = value; + _model.UserName = value; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + if (_lockoutDate.HasValue) + { + _model.LockoutEnd = new DateTimeOffset(_lockoutDate.Value, TimeSpan.Zero); + } + else + { + _model.LockoutEnd = null; + } + + var response = await SystemUserService.CreateSystemUserAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("User created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor b/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor new file mode 100644 index 0000000..aa1700a --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor @@ -0,0 +1,465 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Infrastructure.Authorization.Identity +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Shared.Consts +@using ClosedXML.Excel +@using System.IO +@inject SystemUserService SystemUserService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ User Management + Manage system users, status, and role assignments. +
+
+ + / + Settings + / + System User +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedUser != null) + { + Avatar + Roles + PW + View + Edit + + + } + else + { + + Add New User + + } +
+
+ + + + + + Full Name + + + Email + + Roles + + Status + + + + + + + +
+ + @context.FullName.ToInitial() + + @context.FullName +
+
+ @context.Email + +
+ @foreach (var roleName in ApplicationRoles.AllRoles) + { + + + @roleName + + } +
+
+ + @if (context.IsActive) + { + Active + } + else + { + Inactive + } + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + [Parameter] public EventCallback OnChangePassword { get; set; } + [Parameter] public EventCallback OnManageRoles { get; set; } + [Parameter] public EventCallback OnChangeAvatar { get; set; } + + private List _users = new(); + private GetSystemUserListResponse? _selectedUser; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedUser = null; + StateHasChanged(); + try + { + var response = await SystemUserService.GetSystemUserListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _users = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _users; + return _users.Where(x => + (x.FullName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Email?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedUser = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("SystemUsers"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Full Name"; + worksheet.Cell(currentRow, 2).Value = "Email"; + worksheet.Cell(currentRow, 3).Value = "Roles"; + worksheet.Cell(currentRow, 4).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.FullName; + worksheet.Cell(currentRow, 2).Value = item.Email; + worksheet.Cell(currentRow, 3).Value = string.Join(", ", item.UserRoles); + worksheet.Cell(currentRow, 4).Value = item.IsActive ? "Active" : "Inactive"; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "System_Users_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedUser = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedUser = null; + StateHasChanged(); + } + + private async Task InvokeChangeAvatar() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnChangeAvatar.InvokeAsync(request); + } + + private async Task InvokeManageRoles() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnManageRoles.InvokeAsync(request); + } + + private async Task InvokeChangePassword() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnChangePassword.InvokeAsync(request); + } + + private async Task InvokeEdit() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await SystemUserService.GetSystemUserByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateSystemUserRequest + { + Id = detail.Id, + UserName = detail.UserName, + FullName = detail.FullName, + Email = detail.Email, + PhoneNumber = detail.PhoneNumber, + EmailConfirmed = detail.EmailConfirmed, + PhoneNumberConfirmed = detail.PhoneNumberConfirmed, + TwoFactorEnabled = detail.TwoFactorEnabled, + IsActive = detail.IsActive, + LockoutEnabled = detail.LockoutEnabled, + LockoutEnd = detail.LockoutEnd, + SsoIdFirebase = detail.SsoIdFirebase, + SsoIdKeycloak = detail.SsoIdKeycloak, + SsoIdAzure = detail.SsoIdAzure, + SsoIdAws = detail.SsoIdAws, + SsoIdOther1 = detail.SsoIdOther1, + SsoIdOther2 = detail.SsoIdOther2, + SsoIdOther3 = detail.SsoIdOther3, + CreatedAt = detail.CreatedAt, + LastLoginAt = detail.LastLoginAt, + UpdatedAt = detail.UpdatedAt, + AvatarFile = detail.AvatarFile, + UserRoles = detail.UserRoles ?? new List() + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedUser == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedUser.FullName } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await SystemUserService.DeleteSystemUserByIdAsync(_selectedUser.Id); + if (isSuccess) + { + _selectedUser = null; + await LoadData(); + Snackbar.Add("User deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor b/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor new file mode 100644 index 0000000..9323d1e --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor @@ -0,0 +1,273 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor +@using Indotalent.Shared.Utils +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "User Details" : "Edit User") + @(ReadOnly ? "View complete user account information." : "Update account details and security settings.") +
+
+
+ + + + + + Primary Information + + + + + Full Name + + + + + Email Address & Username + + + Email and Username cannot be changed. Username is linked to the email address. + + + + + Phone Number + + + + + SSO Identifiers + + + + + Firebase ID + + + + Keycloak ID + + + + Azure ID + + + + AWS ID + + + + Other SSO ID 1 + + + + Other SSO ID 2 + + + + Other SSO ID 3 + + + + + Account Settings & Status + + + + + +
+ + General availability of the account in the system. +
+ +
+ + Current status of email verification. +
+ +
+ + Current status of phone number verification. +
+ +
+ + Requirement for Two-Factor Authentication. +
+
+
+ + + +
+ + Allow account to be locked after failed attempts. +
+ +
+ Lockout Until + + Current account lockout expiration date. +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + + Last Login At + @DateTimeExtensions.ToString(_model.LastLoginAt) + + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateSystemUserRequest _model = new(); + private UpdateSystemUserValidator _validator = new(); + private DateTime? _lockoutDate; + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateSystemUserRequest + { + Id = Data.Id, + UserName = Data.UserName, + FullName = Data.FullName, + Email = Data.Email, + PhoneNumber = Data.PhoneNumber, + EmailConfirmed = Data.EmailConfirmed, + PhoneNumberConfirmed = Data.PhoneNumberConfirmed, + TwoFactorEnabled = Data.TwoFactorEnabled, + IsActive = Data.IsActive, + LockoutEnabled = Data.LockoutEnabled, + LockoutEnd = Data.LockoutEnd, + SsoIdFirebase = Data.SsoIdFirebase, + SsoIdKeycloak = Data.SsoIdKeycloak, + SsoIdAzure = Data.SsoIdAzure, + SsoIdAws = Data.SsoIdAws, + SsoIdOther1 = Data.SsoIdOther1, + SsoIdOther2 = Data.SsoIdOther2, + SsoIdOther3 = Data.SsoIdOther3, + CreatedAt = Data.CreatedAt, + LastLoginAt = Data.LastLoginAt, + UpdatedAt = Data.UpdatedAt + }; + + if (_model.LockoutEnd.HasValue) + { + _lockoutDate = _model.LockoutEnd.Value.DateTime; + } + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + if (_lockoutDate.HasValue) + { + _model.LockoutEnd = new DateTimeOffset(_lockoutDate.Value, TimeSpan.Zero); + } + else + { + _model.LockoutEnd = null; + } + + var response = await SystemUserService.UpdateSystemUserAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("User updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs b/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs new file mode 100644 index 0000000..a316f17 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class AdminForgotPasswordResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record AdminForgotPasswordCommand(string UserId) : IRequest; + +public class AdminForgotPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AdminForgotPasswordHandler( + UserManager userManager, + IEmailSender emailSender, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(AdminForgotPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) return new AdminForgotPasswordResponse { IsSuccess = false, Message = "User not found" }; + + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/reset-password?userId={user.Id}&code={code}"; + + var message = $@" +
+

System Administrator Password Reset

+

Hello, {user.FullName}!

+

An administrator has initiated a password reset for your account. Please click the link below to set your new password:

+

Reset My Password

+
+

If you believe this is an error, please contact your IT support.

+
"; + + await _emailSender.SendPasswordResetLinkAsync(user, user.Email!, message); + return new AdminForgotPasswordResponse + { + IsSuccess = true, + Message = "Reset link sent successfully" + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs new file mode 100644 index 0000000..f361059 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangeAvatarResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public class ChangeAvatarRequest +{ + public string UserId { get; set; } = string.Empty; + public byte[] FileData { get; set; } = Array.Empty(); + public string FileName { get; set; } = string.Empty; +} + +public record ChangeAvatarCommand(ChangeAvatarRequest Data) : IRequest; + +public class ChangeAvatarHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly FileStorageService _fileStorage; + + public ChangeAvatarHandler(UserManager userManager, FileStorageService fileStorage) + { + _userManager = userManager; + _fileStorage = fileStorage; + } + + public async Task Handle(ChangeAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeAvatarResponse { IsSuccess = false, Message = "User not found" }; + + try + { + var extension = Path.GetExtension(request.Data.FileName); + + _fileStorage.DeleteOldAvatar(user.AvatarFile); + + var newFileName = await _fileStorage.SaveAvatarAsync(user.Id, request.Data.FileData, extension); + + user.AvatarFile = newFileName; + user.UpdatedAt = DateTime.Now; + + var result = await _userManager.UpdateAsync(user); + + return new ChangeAvatarResponse + { + IsSuccess = result.Succeeded, + Message = result.Succeeded ? "Avatar updated successfully" : "Failed to update database" + }; + } + catch (Exception ex) + { + return new ChangeAvatarResponse { IsSuccess = false, Message = ex.Message }; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs new file mode 100644 index 0000000..c2c97d5 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangePasswordRequest +{ + public string UserId { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; +} + +public class ChangePasswordResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + + + +public record ChangePasswordCommand(ChangePasswordRequest Data) : IRequest; + +public class ChangePasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ChangePasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangePasswordResponse { IsSuccess = false, Message = "User not found" }; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, request.Data.NewPassword); + + if (result.Succeeded) + { + return new ChangePasswordResponse { IsSuccess = true, Message = "Password has been changed successfully" }; + } + + return new ChangePasswordResponse + { + IsSuccess = false, + Message = string.Join(", ", result.Errors.Select(e => e.Description)) + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs b/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs new file mode 100644 index 0000000..e377094 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(6).WithMessage("Password must be at least 6 characters"); + RuleFor(x => x.ConfirmPassword) + .Equal(x => x.NewPassword).WithMessage("Passwords do not match"); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs new file mode 100644 index 0000000..08f44e7 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangeRoleResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public class ChangeRoleRequest +{ + public string UserId { get; set; } = string.Empty; + public List Roles { get; set; } = new(); +} + +public record ChangeRoleCommand(ChangeRoleRequest Data) : IRequest; + +public class ChangeRoleHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ChangeRoleHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ChangeRoleCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeRoleResponse { IsSuccess = false, Message = "User not found" }; + + var currentRoles = await _userManager.GetRolesAsync(user); + + var removeResult = await _userManager.RemoveFromRolesAsync(user, currentRoles); + if (!removeResult.Succeeded) return new ChangeRoleResponse { IsSuccess = false, Message = "Remove role fail" }; + + var addResult = await _userManager.AddToRolesAsync(user, request.Data.Roles); + return new ChangeRoleResponse + { + IsSuccess = true, + Message = "Roles updated successfully" + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs b/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs new file mode 100644 index 0000000..1d1712f --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs @@ -0,0 +1,86 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authorization.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class CreateSystemUserRequest +{ + public string? FullName { get; set; } + public string? Email { get; set; } + public string? Password { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } +} + +public class CreateSystemUserResponse +{ + public string? Id { get; set; } + public string? Email { get; set; } +} + +public record CreateSystemUserCommand(CreateSystemUserRequest Data) : IRequest; + +public class CreateSystemUserHandler : IRequestHandler +{ + private readonly UserManager _userManager; + public CreateSystemUserHandler(UserManager userManager) => _userManager = userManager; + + public async Task Handle(CreateSystemUserCommand request, CancellationToken cancellationToken) + { + var user = new ApplicationUser + { + FullName = request.Data.FullName, + Email = request.Data.Email, + UserName = request.Data.Email, + PhoneNumber = request.Data.PhoneNumber, + EmailConfirmed = request.Data.EmailConfirmed, + PhoneNumberConfirmed = request.Data.PhoneNumberConfirmed, + TwoFactorEnabled = request.Data.TwoFactorEnabled, + IsActive = request.Data.IsActive, + LockoutEnabled = request.Data.LockoutEnabled, + LockoutEnd = request.Data.LockoutEnd, + SsoIdFirebase = request.Data.SsoIdFirebase, + SsoIdKeycloak = request.Data.SsoIdKeycloak, + SsoIdAzure = request.Data.SsoIdAzure, + SsoIdAws = request.Data.SsoIdAws, + SsoIdOther1 = request.Data.SsoIdOther1, + SsoIdOther2 = request.Data.SsoIdOther2, + SsoIdOther3 = request.Data.SsoIdOther3, + CreatedAt = DateTime.Now + }; + + var result = await _userManager.CreateAsync(user, request.Data.Password ?? "123456"); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + throw new Exception(errors); + } + + var rolesToAdd = ApplicationRoles.AllRoles + .Where(role => role != ApplicationRoles.Admin) + .ToList(); + + if (rolesToAdd.Any()) + { + await _userManager.AddToRolesAsync(user, rolesToAdd); + } + + return new CreateSystemUserResponse { Id = user.Id, Email = user.Email }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs b/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs new file mode 100644 index 0000000..630b2a2 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class CreateSystemUserValidator : AbstractValidator +{ + public CreateSystemUserValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required") + .MinimumLength(4).WithMessage("Password must be at least 4 characters"); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SsoIdFirebase).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdKeycloak).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAzure).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAws).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther1).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther2).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther3).MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs b/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs new file mode 100644 index 0000000..86be374 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + + +public record DeleteSystemUserByIdRequest(string Id); + +public record DeleteSystemUserByIdCommand(DeleteSystemUserByIdRequest Data) : IRequest; + +public class DeleteSystemUserByIdHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public DeleteSystemUserByIdHandler(UserManager userManager) => _userManager = userManager; + + public async Task Handle(DeleteSystemUserByIdCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id); + if (user == null) return false; + + var result = await _userManager.DeleteAsync(user); + return result.Succeeded; + } +} diff --git a/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs b/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs new file mode 100644 index 0000000..afa7b6c --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs @@ -0,0 +1,86 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class GetSystemUserByIdResponse +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public int AccessFailedCount { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public List UserRoles { get; set; } = new(); + public string? AvatarFile { get; set; } +} + +public record GetSystemUserByIdQuery(string Id) : IRequest; + +public class GetSystemUserByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSystemUserByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSystemUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await _context.Users + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetSystemUserByIdResponse + { + Id = x.Id, + FullName = x.FullName, + Email = x.Email, + PhoneNumber = x.PhoneNumber, + UserName = x.UserName, + EmailConfirmed = x.EmailConfirmed, + PhoneNumberConfirmed = x.PhoneNumberConfirmed, + TwoFactorEnabled = x.TwoFactorEnabled, + IsActive = x.IsActive, + LockoutEnabled = x.LockoutEnabled, + LockoutEnd = x.LockoutEnd, + AccessFailedCount = x.AccessFailedCount, + SsoIdFirebase = x.SsoIdFirebase, + SsoIdKeycloak = x.SsoIdKeycloak, + SsoIdAzure = x.SsoIdAzure, + SsoIdAws = x.SsoIdAws, + SsoIdOther1 = x.SsoIdOther1, + SsoIdOther2 = x.SsoIdOther2, + SsoIdOther3 = x.SsoIdOther3, + CreatedAt = x.CreatedAt, + LastLoginAt = x.LastLoginAt, + UpdatedAt = x.UpdatedAt, + AvatarFile = x.AvatarFile, + }) + .FirstOrDefaultAsync(cancellationToken); + + if (user == null) return null; + + user.UserRoles = await (from ur in _context.UserRoles + join r in _context.Roles on ur.RoleId equals r.Id + where ur.UserId == user.Id + select r.Name!) + .ToListAsync(cancellationToken); + + return user; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs b/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs new file mode 100644 index 0000000..7431a1f --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class GetSystemUserListResponse +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? AvatarFile { get; set; } + public string? Email { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public bool IsActive { get; set; } + public List UserRoles { get; set; } = new(); +} + +public record GetSystemUserListQuery() : IRequest>; + +public class GetSystemUserListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSystemUserListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSystemUserListQuery request, CancellationToken cancellationToken) + { + var users = await _context.Users + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetSystemUserListResponse + { + Id = x.Id, + FullName = x.FullName, + Email = x.Email, + SsoIdFirebase = x.SsoIdFirebase, + SsoIdKeycloak = x.SsoIdKeycloak, + IsActive = x.IsActive, + AvatarFile = x.AvatarFile, + }) + .ToListAsync(cancellationToken); + + var userIds = users.Select(u => u.Id).ToList(); + + var userRolesMap = await (from ur in _context.UserRoles + join r in _context.Roles on ur.RoleId equals r.Id + where userIds.Contains(ur.UserId) + select new { ur.UserId, r.Name }) + .ToListAsync(cancellationToken); + + foreach (var user in users) + { + user.UserRoles = userRolesMap + .Where(x => x.UserId == user.Id) + .Select(x => x.Name!) + .ToList(); + } + + return users; + } +} diff --git a/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs new file mode 100644 index 0000000..c4afbc7 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs @@ -0,0 +1,87 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class UpdateSystemUserRequest +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public int AccessFailedCount { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public List UserRoles { get; set; } = new(); + public string? AvatarFile { get; set; } +} + +public class UpdateSystemUserResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + + +public record UpdateSystemUserCommand(UpdateSystemUserRequest Data) : IRequest; + +public class UpdateSystemUserHandler : IRequestHandler +{ + private readonly UserManager _userManager; + public UpdateSystemUserHandler(UserManager userManager) => _userManager = userManager; + + public async Task Handle(UpdateSystemUserCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id ?? string.Empty); + + if (user == null) + { + return new UpdateSystemUserResponse { Id = request.Data.Id, Success = false }; + } + + user.FullName = request.Data.FullName; + user.PhoneNumber = request.Data.PhoneNumber; + user.IsActive = request.Data.IsActive; + user.EmailConfirmed = request.Data.EmailConfirmed; + user.PhoneNumberConfirmed = request.Data.PhoneNumberConfirmed; + user.TwoFactorEnabled = request.Data.TwoFactorEnabled; + user.LockoutEnabled = request.Data.LockoutEnabled; + user.LockoutEnd = request.Data.LockoutEnd; + + user.SsoIdFirebase = request.Data.SsoIdFirebase; + user.SsoIdKeycloak = request.Data.SsoIdKeycloak; + user.SsoIdAzure = request.Data.SsoIdAzure; + user.SsoIdAws = request.Data.SsoIdAws; + user.SsoIdOther1 = request.Data.SsoIdOther1; + user.SsoIdOther2 = request.Data.SsoIdOther2; + user.SsoIdOther3 = request.Data.SsoIdOther3; + + user.UpdatedAt = DateTime.Now; + + user.AvatarFile = request.Data.AvatarFile; + + var result = await _userManager.UpdateAsync(user); + + return new UpdateSystemUserResponse + { + Id = user.Id, + Success = result.Succeeded + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs new file mode 100644 index 0000000..6ddc5a1 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class UpdateSystemUserValidator : AbstractValidator +{ + public UpdateSystemUserValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("User ID is required for update"); + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SsoIdFirebase).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdKeycloak).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAzure).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAws).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther1).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther2).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther3).MaximumLength(GlobalConsts.StringLengthMedium); + } +} diff --git a/Features/Setting/SystemUser/SystemUserEndpoint.cs b/Features/Setting/SystemUser/SystemUserEndpoint.cs new file mode 100644 index 0000000..1ee16a7 --- /dev/null +++ b/Features/Setting/SystemUser/SystemUserEndpoint.cs @@ -0,0 +1,118 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.SystemUser.Cqrs; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Setting.SystemUser; + +public static class SystemUserEndpoint +{ + public static void MapSystemUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/system-user").WithTags("SystemUsers") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSystemUserListQuery()); + return result.ToApiResponse("User list retrieved successfully"); + }) + .WithName("GetSystemUserList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSystemUserByIdQuery(id)); + return result.ToApiResponse(result is not null ? "User detail retrieved" : "User not found"); + }) + .WithName("GetSystemUserById"); + + group.MapPost("/", async (CreateSystemUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSystemUserCommand(request)); + return result.ToApiResponse("User created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSystemUser"); + + group.MapPost("/update", async (UpdateSystemUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSystemUserCommand(request)); + return result.Success + ? result.ToApiResponse("User updated successfully") + : ((object?)null).ToApiResponse("Update failed"); + }) + .WithName("UpdateSystemUser"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSystemUserByIdCommand(new DeleteSystemUserByIdRequest(id))); + return result.ToApiResponse("User deleted successfully"); + }) + .WithName("DeleteSystemUserById"); + + group.MapPost("/change-password", async (ChangePasswordRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangePasswordCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangePassword"); + + group.MapPost("/send-reset-link/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new AdminForgotPasswordCommand(id)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("AdminSendResetLink"); + + group.MapPost("/change-role", async (ChangeRoleRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeRoleCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangeRole"); + + group.MapPost("/change-avatar", async (ChangeAvatarRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeAvatarCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangeAvatar"); + + group.MapGet("/avatar-image/{fileName}", async (string fileName, IOptions options, IWebHostEnvironment env) => + { + var avatarSettings = options.Value.Avatar; + + var folderPath = Path.Combine(env.WebRootPath, avatarSettings.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (!System.IO.File.Exists(filePath)) + { + return Results.NotFound(); + } + + var bytes = await System.IO.File.ReadAllBytesAsync(filePath); + + var extension = Path.GetExtension(fileName).ToLower(); + var contentType = extension switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + _ => "application/octet-stream" + }; + + return Results.File(bytes, contentType); + }) + .WithName("GetAvatarImage"); + + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/SystemUserService.cs b/Features/Setting/SystemUser/SystemUserService.cs new file mode 100644 index 0000000..06e254a --- /dev/null +++ b/Features/Setting/SystemUser/SystemUserService.cs @@ -0,0 +1,109 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.SystemUser.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.SystemUser; + +public class SystemUserService : BaseService +{ + private readonly RestClient _client; + + public SystemUserService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSystemUserListAsync() + { + var request = new RestRequest("api/system-user", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSystemUserByIdAsync(string id) + { + var request = new RestRequest($"api/system-user/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSystemUserAsync(CreateSystemUserRequest data) + { + var request = new RestRequest("api/system-user", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSystemUserAsync(UpdateSystemUserRequest data) + { + var request = new RestRequest("api/system-user/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSystemUserByIdAsync(string id) + { + var request = new RestRequest($"api/system-user/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> ChangePasswordAsync(ChangePasswordRequest data) + { + var request = new RestRequest("api/system-user/change-password", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task SendResetLinkAsync(string userId) + { + var request = new RestRequest($"api/system-user/send-reset-link/{userId}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task ChangeRoleAsync(ChangeRoleRequest data) + { + var request = new RestRequest("api/system-user/change-role", Method.Post); + request.AddJsonBody(data); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> ChangeAvatarAsync(ChangeAvatarRequest data) + { + var request = new RestRequest("api/system-user/change-avatar", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + + public async Task GetAvatarBlobAsync(string fileName) + { + var request = new RestRequest($"api/system-user/avatar-image/{fileName}", Method.Get); + + if (!string.IsNullOrEmpty(TokenProvider.Token)) + { + request.AddHeader("Authorization", $"Bearer {TokenProvider.Token}"); + } + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddHeader("X-UserId", CurrentUserService.UserId); + } + + var response = await _client.ExecuteAsync(request); + + return response.IsSuccessful ? response.RawBytes : null; + } + +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/TaxPage.razor b/Features/Setting/Tax/Components/TaxPage.razor new file mode 100644 index 0000000..c3a9b5e --- /dev/null +++ b/Features/Setting/Tax/Components/TaxPage.razor @@ -0,0 +1,52 @@ +@page "/setting/tax" +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TaxCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TaxUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_TaxDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTaxRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTaxRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxCreateForm.razor b/Features/Setting/Tax/Components/_TaxCreateForm.razor new file mode 100644 index 0000000..ae11ba5 --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxCreateForm.razor @@ -0,0 +1,117 @@ +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TaxService TaxService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Tax + Enter tax details and percentage rates. +
+
+
+ + + + + + Tax Code + + + + Percentage (%) + + + + Tax Name + + + + Category + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Tax + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTaxValidator _validator = new(); + private CreateTaxRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TaxService.CreateTaxAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Tax created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxDataTable.razor b/Features/Setting/Tax/Components/_TaxDataTable.razor new file mode 100644 index 0000000..f15e5ce --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxDataTable.razor @@ -0,0 +1,402 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TaxService TaxService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Tax Management + Manage tax codes, categories, and percentage rates. +
+
+ + / + Settings + / + Tax +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedTax != null) + { + View + Edit + Remove + + } + else + { + + Add New Tax + + } +
+
+ + + + + + Tax Name + + + Code + + + Percentage + + + Category + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Code + @context.PercentageValue% + @context.Category +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _taxes = new(); + private GetTaxListResponse? _selectedTax; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedTax = null; + StateHasChanged(); + try + { + var response = await TaxService.GetTaxListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _taxes = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _taxes; + return _taxes.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Category?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedTax = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Taxes"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Tax Name"; + worksheet.Cell(currentRow, 2).Value = "Code"; + worksheet.Cell(currentRow, 3).Value = "Percentage"; + worksheet.Cell(currentRow, 4).Value = "Category"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Code; + worksheet.Cell(currentRow, 3).Value = item.PercentageValue; + worksheet.Cell(currentRow, 4).Value = item.Category; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Tax_Rates_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedTax = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedTax = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedTax == null) return; + var request = await MapToUpdateRequest(_selectedTax.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedTax == null) return; + var request = await MapToUpdateRequest(_selectedTax.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await TaxService.GetTaxByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateTaxRequest + { + Id = detail.Id, + Code = detail.Code, + Name = detail.Name, + PercentageValue = detail.PercentageValue, + Category = detail.Category, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedTax == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedTax.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await TaxService.DeleteTaxByIdAsync(_selectedTax.Id); + if (isSuccess) + { + _selectedTax = null; + await LoadData(); + Snackbar.Add("Tax deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxUpdateForm.razor b/Features/Setting/Tax/Components/_TaxUpdateForm.razor new file mode 100644 index 0000000..dc4525e --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxUpdateForm.razor @@ -0,0 +1,169 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TaxService TaxService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Tax Details" : "Edit Tax") + @(ReadOnly ? "Viewing tax configuration." : "Modify existing tax information.") +
+
+
+ + + + + + Tax Code + + + + Percentage (%) + + + + Tax Name + + + + Category + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateTaxRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateTaxValidator _validator = new(); + private UpdateTaxRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateTaxRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + PercentageValue = Data.PercentageValue, + Category = Data.Category, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TaxService.UpdateTaxAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Tax updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs b/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs new file mode 100644 index 0000000..e0c36f2 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs @@ -0,0 +1,69 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class CreateTaxRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } +} + +public class CreateTaxResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateTaxCommand(CreateTaxRequest Data) : IRequest; + +public class CreateTaxHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTaxHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTaxCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tax + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tax", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Tax); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Tax + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + PercentageValue = request.Data.PercentageValue, + Category = request.Data.Category, + Description = request.Data.Description + }; + + _context.Tax.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTaxResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs b/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs new file mode 100644 index 0000000..ab86a40 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class CreateTaxValidator : AbstractValidator +{ + public CreateTaxValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Tax Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tax Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs b/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs new file mode 100644 index 0000000..d7a97cb --- /dev/null +++ b/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public record DeleteTaxByIdRequest(string Id); + +public class DeleteTaxByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTaxByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTaxByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Tax + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Tax.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} + +public record DeleteTaxByIdCommand(DeleteTaxByIdRequest Data) : IRequest; \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs b/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs new file mode 100644 index 0000000..393b2f3 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class GetTaxByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTaxByIdQuery(string Id) : IRequest; + +public class GetTaxByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTaxByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTaxByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Tax + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetTaxByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + PercentageValue = x.PercentageValue, + Category = x.Category, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs b/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs new file mode 100644 index 0000000..5650e8f --- /dev/null +++ b/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class GetTaxListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTaxListQuery() : IRequest>; + +public class GetTaxListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTaxListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTaxListQuery request, CancellationToken cancellationToken) + { + return await _context.Tax + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetTaxListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + PercentageValue = x.PercentageValue, + Category = x.Category, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs b/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs new file mode 100644 index 0000000..d65c8bb --- /dev/null +++ b/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class UpdateTaxRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTaxResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTaxCommand(UpdateTaxRequest Data) : IRequest; + +public class UpdateTaxHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTaxHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTaxCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tax + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tax", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Tax + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTaxResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.PercentageValue = request.Data.PercentageValue; + entity.Category = request.Data.Category; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTaxResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs b/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs new file mode 100644 index 0000000..e1335d1 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class UpdateTaxValidator : AbstractValidator +{ + public UpdateTaxValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Tax Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tax Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/TaxEndpoint.cs b/Features/Setting/Tax/TaxEndpoint.cs new file mode 100644 index 0000000..336ab0c --- /dev/null +++ b/Features/Setting/Tax/TaxEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Tax.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Tax; + +public static class TaxEndpoint +{ + public static void MapTaxEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tax").WithTags("Taxes") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTaxListQuery()); + + return result.ToApiResponse("Data tax retrieved successfully"); + }) + .WithName("GetTaxList") + .WithTags("Taxes"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTaxByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Tax detail retrieved successfully" + : $"Tax with ID {id} not found"); + }) + .WithName("GetTaxById") + .WithTags("Taxes"); + + group.MapPost("/", async (CreateTaxRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTaxCommand(request)); + + return result.ToApiResponse("Tax has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTax") + .WithTags("Taxes"); + + group.MapPost("/update", async (UpdateTaxRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTaxCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The tax data could not be found."); + } + + return result.ToApiResponse("Tax has been updated successfully"); + }) + .WithName("UpdateTax") + .WithTags("Taxes"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTaxByIdCommand(new DeleteTaxByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The tax data could not be found."); + } + + return true.ToApiResponse("Tax has been deleted successfully"); + }) + .WithName("DeleteTaxById") + .WithTags("Taxes"); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/TaxService.cs b/Features/Setting/Tax/TaxService.cs new file mode 100644 index 0000000..8f29397 --- /dev/null +++ b/Features/Setting/Tax/TaxService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Tax.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Tax; + +public class TaxService : BaseService +{ + private readonly RestClient _client; + + public TaxService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTaxListAsync() + { + var request = new RestRequest("api/tax", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTaxByIdAsync(string id) + { + var request = new RestRequest($"api/tax/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTaxAsync(CreateTaxRequest data) + { + var request = new RestRequest("api/tax", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTaxByIdAsync(string id) + { + var request = new RestRequest($"api/tax/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTaxAsync(UpdateTaxRequest data) + { + var request = new RestRequest("api/tax/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/CustomerPage.razor b/Features/Thirdparty/Customer/Components/CustomerPage.razor new file mode 100644 index 0000000..0b42072 --- /dev/null +++ b/Features/Thirdparty/Customer/Components/CustomerPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/customer" +@using Indotalent.Features.Thirdparty.Customer +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using Indotalent.Features.Thirdparty.Customer.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CustomerCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CustomerUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_CustomerDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCustomerRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateCustomerRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerContactCreateForm.razor b/Features/Thirdparty/Customer/Components/_CustomerContactCreateForm.razor new file mode 100644 index 0000000..6b7c2d4 --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerContactCreateForm.razor @@ -0,0 +1,77 @@ +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using MudBlazor +@inject CustomerService CustomerService +@inject ISnackbar Snackbar + + + + + + + Contact Name + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Add Contact + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string CustomerId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateCustomerContactRequest _model = new(); + private bool _processing = false; + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + _model.CustomerId = CustomerId; + try + { + var response = await CustomerService.CreateCustomerContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Contact added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerContactDataTable.razor b/Features/Thirdparty/Customer/Components/_CustomerContactDataTable.razor new file mode 100644 index 0000000..5a785d3 --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerContactDataTable.razor @@ -0,0 +1,101 @@ +@using Indotalent.Features.Thirdparty.Customer +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using MudBlazor +@using Features.Root.Shared +@inject CustomerService CustomerService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Customer Contacts + @if (!ReadOnly) + { + + Add Contact + + } +
+ + + + Name + Job Title + Contact Info + Description + @if (!ReadOnly) + { + Actions + } + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.JobTitle + +
+ @context.EmailAddress + @context.PhoneNumber +
+
+ @context.Description + @if (!ReadOnly) + { + + + + + } +
+
+
+ + + + +@code { + [Parameter] public string CustomerId { get; set; } = string.Empty; + [Parameter] public List Contacts { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["CustomerId"] = CustomerId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_CustomerContactCreateForm>("Add Customer Contact", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(CustomerContactItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_CustomerContactUpdateForm>("Edit Customer Contact", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(CustomerContactItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove {item.Name} from contacts?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await CustomerService.DeleteCustomerContactAsync(item.Id!); + if (success) + { + Snackbar.Add("Contact removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerContactUpdateForm.razor b/Features/Thirdparty/Customer/Components/_CustomerContactUpdateForm.razor new file mode 100644 index 0000000..25cb878 --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerContactUpdateForm.razor @@ -0,0 +1,86 @@ +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using MudBlazor +@inject CustomerService CustomerService +@inject ISnackbar Snackbar + + + + + + + Contact Name + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public CustomerContactItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateCustomerContactRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Name = Data.Name; + _model.JobTitle = Data.JobTitle; + _model.PhoneNumber = Data.PhoneNumber; + _model.EmailAddress = Data.EmailAddress; + _model.Description = Data.Description; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerService.UpdateCustomerContactAsync(_model); + await Task.Delay(500); + if (response != null && response.Value!.Success) + { + Snackbar.Add("Contact updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerCreateForm.razor b/Features/Thirdparty/Customer/Components/_CustomerCreateForm.razor new file mode 100644 index 0000000..bc7a93d --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerCreateForm.razor @@ -0,0 +1,173 @@ +@using Indotalent.Features.Thirdparty.Customer +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerService CustomerService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Customer + Complete the profile and contact details of the customer. +
+
+
+ + + + + + General Information + + + + Customer Name + + + + Customer Group + + @foreach (var item in _lookupData.CustomerGroups) + { + @item.Name + } + + + + Customer Category + + @foreach (var item in _lookupData.CustomerCategories) + { + @item.Name + } + + + + Description + + + + + Address Information + + + + Street + + + + City + + + + State / Province + + + + Zip Code + + + + Country + + + + + Contact & Online + + + + Phone Number + + + + Fax Number + + + + WhatsApp + + + + Email Address + + + + Website + + + + + Social Media + + + + LinkedIn + + + + Facebook + + + + Instagram + + + + Twitter (X) + + + + TikTok + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Customer + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateCustomerValidator _validator = new(); + private CreateCustomerRequest _model = new(); + private LookupCustomerResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await CustomerService.GetCustomerLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerService.CreateCustomerAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerDataTable.razor b/Features/Thirdparty/Customer/Components/_CustomerDataTable.razor new file mode 100644 index 0000000..3773b7d --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerDataTable.razor @@ -0,0 +1,279 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.Customer +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CustomerService CustomerService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Customer Management + Maintain your customer database and contact information. +
+
+ + / + Third Party + / + Customer +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCustomer != null) + { + View + Edit + Remove + + } + else + { + + Add New Customer + + } +
+
+ + + + + + Customer + + + Group + + Contact + Category + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.CustomerGroupName + +
+ @context.PhoneNumber + @context.EmailAddress +
+
+ + @context.CustomerCategoryName + +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _customers = new(); + private GetCustomerListResponse? _selectedCustomer; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCustomer = null; + StateHasChanged(); + try + { + var response = await CustomerService.GetCustomerListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _customers = response.Value ?? new List(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _customers; + return _customers.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedCustomer = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Customers"); + worksheet.Cell(1, 1).Value = "Customer Name"; + worksheet.Cell(1, 2).Value = "Number"; + worksheet.Cell(1, 3).Value = "Phone"; + worksheet.Cell(1, 4).Value = "Email"; + worksheet.Range(1, 1, 1, 4).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.AutoNumber; + worksheet.Cell(currentRow, 3).Value = item.PhoneNumber; + worksheet.Cell(currentRow, 4).Value = item.EmailAddress; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Customer_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedCustomer = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedCustomer = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedCustomer != null) { var req = await MapToUpdateRequest(_selectedCustomer.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedCustomer != null) { var req = await MapToUpdateRequest(_selectedCustomer.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await CustomerService.GetCustomerByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateCustomerRequest { Id = d.Id, Name = d.Name, Description = d.Description, Street = d.Street, City = d.City, State = d.State, ZipCode = d.ZipCode, Country = d.Country, PhoneNumber = d.PhoneNumber, FaxNumber = d.FaxNumber, EmailAddress = d.EmailAddress, Website = d.Website, WhatsApp = d.WhatsApp, LinkedIn = d.LinkedIn, Facebook = d.Facebook, Instagram = d.Instagram, TwitterX = d.TwitterX, TikTok = d.TikTok, CustomerGroupId = d.CustomerGroupId, CustomerCategoryId = d.CustomerCategoryId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCustomer == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCustomer.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await CustomerService.DeleteCustomerByIdAsync(_selectedCustomer.Id); + if (success) { _selectedCustomer = null; await LoadData(); Snackbar.Add("Customer deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Components/_CustomerUpdateForm.razor b/Features/Thirdparty/Customer/Components/_CustomerUpdateForm.razor new file mode 100644 index 0000000..22af893 --- /dev/null +++ b/Features/Thirdparty/Customer/Components/_CustomerUpdateForm.razor @@ -0,0 +1,251 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.Customer +@using Indotalent.Features.Thirdparty.Customer.Cqrs +@using Indotalent.Features.Thirdparty.Customer.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerService CustomerService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Customer Profile" : "Edit Customer") + @(ReadOnly ? "Viewing detailed customer records." : "Update customer account information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + General Information + + + + Customer Name + + + + Customer Group + + @foreach (var item in _lookupData.CustomerGroups) + { + @item.Name + } + + + + Customer Category + + @foreach (var item in _lookupData.CustomerCategories) + { + @item.Name + } + + + + Description + + + + + <_CustomerContactDataTable Contacts="_model.Contacts" CustomerId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshData" /> + + + + Address Information + + + + Street + + + + City + + + + State / Province + + + + Zip Code + + + + Country + + + + + Contact & Online + + + + Phone Number + + + + Fax Number + + + + WhatsApp + + + + Email Address + + + + Website + + + + + Social Media + + + + LinkedIn + + + + Facebook + + + + Instagram + + + + Twitter (X) + + + + TikTok + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateCustomerRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateCustomerValidator _validator = new(); + private GetCustomerByIdResponse _model = new(); + private LookupCustomerResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + await RefreshData(); + } + + private async Task RefreshData() + { + _isDataLoading = true; + try + { + var response = await CustomerService.GetCustomerLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + + var customer = await CustomerService.GetCustomerByIdAsync(Data.Id!); + if (customer != null && customer.IsSuccess) { _model = customer.Value!; } + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var updateRequest = new UpdateCustomerRequest + { + Id = _model.Id, + Name = _model.Name, + Description = _model.Description, + Street = _model.Street, + City = _model.City, + State = _model.State, + ZipCode = _model.ZipCode, + Country = _model.Country, + PhoneNumber = _model.PhoneNumber, + FaxNumber = _model.FaxNumber, + EmailAddress = _model.EmailAddress, + Website = _model.Website, + WhatsApp = _model.WhatsApp, + LinkedIn = _model.LinkedIn, + Facebook = _model.Facebook, + Instagram = _model.Instagram, + TwitterX = _model.TwitterX, + TikTok = _model.TikTok, + CustomerGroupId = _model.CustomerGroupId, + CustomerCategoryId = _model.CustomerCategoryId + }; + + var response = await CustomerService.UpdateCustomerAsync(updateRequest); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/CreateCustomerContactHandler.cs b/Features/Thirdparty/Customer/Cqrs/CreateCustomerContactHandler.cs new file mode 100644 index 0000000..ca7f357 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/CreateCustomerContactHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class CreateCustomerContactRequest +{ + public string? CustomerId { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class CreateCustomerContactResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCustomerContactCommand(CreateCustomerContactRequest Data) : IRequest; + +public class CreateCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCustomerContactCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.CustomerContact); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.CustomerContact + { + AutoNumber = autoNo, + CustomerId = request.Data.CustomerId, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description + }; + + _context.CustomerContact.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCustomerContactResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/CreateCustomerHandler.cs b/Features/Thirdparty/Customer/Cqrs/CreateCustomerHandler.cs new file mode 100644 index 0000000..7b7e923 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/CreateCustomerHandler.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class CreateCustomerRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? CustomerGroupId { get; set; } + public string? CustomerCategoryId { get; set; } +} + +public class CreateCustomerResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCustomerCommand(CreateCustomerRequest Data) : IRequest; + +public class CreateCustomerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCustomerHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCustomerCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Customer + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer", request.Data.Name ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Customer); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Customer + { + AutoNumber = autoNo, + Name = request.Data.Name, + Description = request.Data.Description, + Street = request.Data.Street, + City = request.Data.City, + State = request.Data.State, + ZipCode = request.Data.ZipCode, + Country = request.Data.Country, + PhoneNumber = request.Data.PhoneNumber, + FaxNumber = request.Data.FaxNumber, + EmailAddress = request.Data.EmailAddress, + Website = request.Data.Website, + WhatsApp = request.Data.WhatsApp, + LinkedIn = request.Data.LinkedIn, + Facebook = request.Data.Facebook, + Instagram = request.Data.Instagram, + TwitterX = request.Data.TwitterX, + TikTok = request.Data.TikTok, + CustomerGroupId = request.Data.CustomerGroupId, + CustomerCategoryId = request.Data.CustomerCategoryId + }; + + _context.Customer.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCustomerResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/CreateCustomerValidator.cs b/Features/Thirdparty/Customer/Cqrs/CreateCustomerValidator.cs new file mode 100644 index 0000000..1e3e702 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/CreateCustomerValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class CreateCustomerValidator : AbstractValidator +{ + public CreateCustomerValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Customer Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + + RuleFor(x => x.CustomerGroupId) + .NotEmpty().WithMessage("Customer Group is required"); + + RuleFor(x => x.CustomerCategoryId) + .NotEmpty().WithMessage("Customer Category is required"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/DeleteCustomerByIdHandler.cs b/Features/Thirdparty/Customer/Cqrs/DeleteCustomerByIdHandler.cs new file mode 100644 index 0000000..22527d9 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/DeleteCustomerByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public record DeleteCustomerByIdRequest(string Id); + +public record DeleteCustomerByIdCommand(DeleteCustomerByIdRequest Data) : IRequest; + +public class DeleteCustomerByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCustomerByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCustomerByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Customer + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Customer.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/DeleteCustomerContactHandler.cs b/Features/Thirdparty/Customer/Cqrs/DeleteCustomerContactHandler.cs new file mode 100644 index 0000000..ffa90ce --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/DeleteCustomerContactHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public record DeleteCustomerContactCommand(string Id) : IRequest; + +public class DeleteCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCustomerContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerContact + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.CustomerContact.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/GetCustomerByIdHandler.cs b/Features/Thirdparty/Customer/Cqrs/GetCustomerByIdHandler.cs new file mode 100644 index 0000000..213b2ab --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/GetCustomerByIdHandler.cs @@ -0,0 +1,104 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class CustomerContactItemResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class GetCustomerByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? CustomerGroupId { get; set; } + public string? CustomerCategoryId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Contacts { get; set; } = new(); +} + +public record GetCustomerByIdQuery(string Id) : IRequest; + +public class GetCustomerByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCustomerByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCustomerByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Customer + .AsNoTracking() + .Include(x => x.CustomerContactList) + .Where(x => x.Id == request.Id) + .Select(x => new GetCustomerByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + Description = x.Description, + Street = x.Street, + City = x.City, + State = x.State, + ZipCode = x.ZipCode, + Country = x.Country, + PhoneNumber = x.PhoneNumber, + FaxNumber = x.FaxNumber, + EmailAddress = x.EmailAddress, + Website = x.Website, + WhatsApp = x.WhatsApp, + LinkedIn = x.LinkedIn, + Facebook = x.Facebook, + Instagram = x.Instagram, + TwitterX = x.TwitterX, + TikTok = x.TikTok, + CustomerGroupId = x.CustomerGroupId, + CustomerCategoryId = x.CustomerCategoryId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Contacts = x.CustomerContactList + .OrderBy(c => c.Name) + .Select(c => new CustomerContactItemResponse + { + Id = c.Id, + Name = c.Name, + AutoNumber = c.AutoNumber, + JobTitle = c.JobTitle, + PhoneNumber = c.PhoneNumber, + EmailAddress = c.EmailAddress, + Description = c.Description + }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/GetCustomerListHandler.cs b/Features/Thirdparty/Customer/Cqrs/GetCustomerListHandler.cs new file mode 100644 index 0000000..196c9ba --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/GetCustomerListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class GetCustomerListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? CustomerGroupName { get; set; } + public string? CustomerCategoryName { get; set; } +} + +public record GetCustomerListQuery() : IRequest>; + +public class GetCustomerListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCustomerListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCustomerListQuery request, CancellationToken cancellationToken) + { + return await _context.Customer + .AsNoTracking() + .Include(x => x.CustomerGroup) + .Include(x => x.CustomerCategory) + .OrderBy(x => x.Name) + .Select(x => new GetCustomerListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + CustomerGroupName = x.CustomerGroup != null ? x.CustomerGroup.Name : string.Empty, + CustomerCategoryName = x.CustomerCategory != null ? x.CustomerCategory.Name : string.Empty + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/LookupCustomerHandler.cs b/Features/Thirdparty/Customer/Cqrs/LookupCustomerHandler.cs new file mode 100644 index 0000000..64d95dc --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/LookupCustomerHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class LookupCustomerResponse +{ + public List CustomerGroups { get; set; } = new(); + public List CustomerCategories { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupCustomerQuery() : IRequest; + +public class LookupCustomerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupCustomerHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupCustomerQuery request, CancellationToken cancellationToken) + { + var result = new LookupCustomerResponse(); + + result.CustomerGroups = await _context.CustomerGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + result.CustomerCategories = await _context.CustomerCategory + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/UpdateCustomerContactHandler.cs b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerContactHandler.cs new file mode 100644 index 0000000..a6f2140 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerContactHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class UpdateCustomerContactRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class UpdateCustomerContactResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCustomerContactCommand(UpdateCustomerContactRequest Data) : IRequest; + +public class UpdateCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCustomerContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCustomerContactResponse + { + Id = request.Data.Id, + Success = false + }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCustomerContactResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/UpdateCustomerHandler.cs b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerHandler.cs new file mode 100644 index 0000000..9f194b4 --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerHandler.cs @@ -0,0 +1,96 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class UpdateCustomerRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? CustomerGroupId { get; set; } + public string? CustomerCategoryId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCustomerResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCustomerCommand(UpdateCustomerRequest Data) : IRequest; + +public class UpdateCustomerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCustomerHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCustomerCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Customer + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer", request.Data.Name ?? string.Empty); + } + + var entity = await _context.Customer + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCustomerResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Street = request.Data.Street; + entity.City = request.Data.City; + entity.State = request.Data.State; + entity.ZipCode = request.Data.ZipCode; + entity.Country = request.Data.Country; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.FaxNumber = request.Data.FaxNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Website = request.Data.Website; + entity.WhatsApp = request.Data.WhatsApp; + entity.LinkedIn = request.Data.LinkedIn; + entity.Facebook = request.Data.Facebook; + entity.Instagram = request.Data.Instagram; + entity.TwitterX = request.Data.TwitterX; + entity.TikTok = request.Data.TikTok; + entity.CustomerGroupId = request.Data.CustomerGroupId; + entity.CustomerCategoryId = request.Data.CustomerCategoryId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCustomerResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/Cqrs/UpdateCustomerValidator.cs b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerValidator.cs new file mode 100644 index 0000000..fcc87cb --- /dev/null +++ b/Features/Thirdparty/Customer/Cqrs/UpdateCustomerValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.Customer.Cqrs; + +public class UpdateCustomerValidator : AbstractValidator +{ + public UpdateCustomerValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Customer Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + + RuleFor(x => x.CustomerGroupId) + .NotEmpty().WithMessage("Customer Group is required"); + + RuleFor(x => x.CustomerCategoryId) + .NotEmpty().WithMessage("Customer Category is required"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/CustomerEndpoint.cs b/Features/Thirdparty/Customer/CustomerEndpoint.cs new file mode 100644 index 0000000..6c0ff14 --- /dev/null +++ b/Features/Thirdparty/Customer/CustomerEndpoint.cs @@ -0,0 +1,106 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.Customer.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.Customer; + +public static class CustomerEndpoint +{ + public static void MapCustomerEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/customer").WithTags("Customers") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerListQuery()); + return result.ToApiResponse("Customer list retrieved successfully"); + }) + .WithName("GetCustomerList") + .WithTags("Customers"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Customer detail retrieved successfully" + : $"Customer with ID {id} not found"); + }) + .WithName("GetCustomerById") + .WithTags("Customers"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupCustomerQuery()); + return result.ToApiResponse("Customer lookup data retrieved successfully"); + }) + .WithName("GetCustomerLookup") + .WithTags("Customers"); + + group.MapPost("/", async (CreateCustomerRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCustomerCommand(request)); + return result.ToApiResponse("Customer has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCustomer") + .WithTags("Customers"); + + group.MapPost("/update", async (UpdateCustomerRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCustomerCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The customer data could not be found."); + } + return result.ToApiResponse("Customer has been updated successfully"); + }) + .WithName("UpdateCustomer") + .WithTags("Customers"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCustomerByIdCommand(new DeleteCustomerByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The customer data could not be found."); + } + return true.ToApiResponse("Customer has been deleted successfully"); + }) + .WithName("DeleteCustomerById") + .WithTags("Customers"); + + group.MapPost("/customer-contact", async (CreateCustomerContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCustomerContactCommand(request)); + return result.ToApiResponse("Customer contact has been added successfully"); + }) + .WithName("CreateCustomerContactChild") + .WithTags("Customers"); + + group.MapPost("/customer-contact/update", async (UpdateCustomerContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCustomerContactCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Customer contact not found."); + } + return result.ToApiResponse("Customer contact has been updated successfully"); + }) + .WithName("UpdateCustomerContactChild") + .WithTags("Customers"); + + group.MapPost("/customer-contact/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCustomerContactCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Customer contact not found."); + } + return true.ToApiResponse("Customer contact has been removed successfully"); + }) + .WithName("DeleteCustomerContactChild") + .WithTags("Customers"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Customer/CustomerService.cs b/Features/Thirdparty/Customer/CustomerService.cs new file mode 100644 index 0000000..1f25ef8 --- /dev/null +++ b/Features/Thirdparty/Customer/CustomerService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.Customer.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.Customer; + +public class CustomerService : BaseService +{ + private readonly RestClient _client; + + public CustomerService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCustomerListAsync() + { + var request = new RestRequest("api/customer", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCustomerByIdAsync(string id) + { + var request = new RestRequest($"api/customer/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetCustomerLookupAsync() + { + var request = new RestRequest("api/customer/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCustomerAsync(CreateCustomerRequest data) + { + var request = new RestRequest("api/customer", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCustomerByIdAsync(string id) + { + var request = new RestRequest($"api/customer/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCustomerAsync(UpdateCustomerRequest data) + { + var request = new RestRequest("api/customer/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCustomerContactAsync(CreateCustomerContactRequest data) + { + var request = new RestRequest("api/customer/customer-contact", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateCustomerContactAsync(UpdateCustomerContactRequest data) + { + var request = new RestRequest("api/customer/customer-contact/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCustomerContactAsync(string id) + { + var request = new RestRequest($"api/customer/customer-contact/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Components/CustomerCategoryPage.razor b/Features/Thirdparty/CustomerCategory/Components/CustomerCategoryPage.razor new file mode 100644 index 0000000..8070fb9 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Components/CustomerCategoryPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/customer-category" +@using Indotalent.Features.Thirdparty.CustomerCategory +@using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs +@using Indotalent.Features.Thirdparty.CustomerCategory.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CustomerCategoryCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CustomerCategoryUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_CustomerCategoryDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCustomerCategoryRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateCustomerCategoryRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryCreateForm.razor b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryCreateForm.razor new file mode 100644 index 0000000..1cbcc48 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryCreateForm.razor @@ -0,0 +1,68 @@ +@using Indotalent.Features.Thirdparty.CustomerCategory +@using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerCategoryService CustomerCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Category + Establish new classification for your customers. +
+
+
+ + + + + + Category Name + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Category + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateCustomerCategoryValidator _validator = new(); + private CreateCustomerCategoryRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerCategoryService.CreateCustomerCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer category created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryDataTable.razor b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryDataTable.razor new file mode 100644 index 0000000..1b9829a --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryDataTable.razor @@ -0,0 +1,278 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerCategory +@using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CustomerCategoryService CustomerCategoryService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Customer Category + Organize and define customer classifications. +
+
+ + / + Third Party + / + Customer Category +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCategory != null) + { + View + Edit + Remove + + } + else + { + + Add New Category + + } +
+
+ + + + + + Category Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _categories = new(); + private GetCustomerCategoryListResponse? _selectedCategory; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCategory = null; + StateHasChanged(); + try + { + var response = await CustomerCategoryService.GetCustomerCategoryListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _categories = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _categories; + return _categories.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedCategory = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("CustomerCategories"); + worksheet.Cell(1, 1).Value = "Category Name"; + worksheet.Cell(1, 2).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "CustomerCategory_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedCategory = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedCategory = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedCategory != null) { var req = await MapToUpdateRequest(_selectedCategory.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedCategory != null) { var req = await MapToUpdateRequest(_selectedCategory.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await CustomerCategoryService.GetCustomerCategoryByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateCustomerCategoryRequest { Id = d.Id, Name = d.Name, Description = d.Description, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCategory == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCategory.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await CustomerCategoryService.DeleteCustomerCategoryByIdAsync(_selectedCategory.Id); + if (isSuccess) { _selectedCategory = null; await LoadData(); Snackbar.Add("Category deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryUpdateForm.razor b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryUpdateForm.razor new file mode 100644 index 0000000..2105c79 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Components/_CustomerCategoryUpdateForm.razor @@ -0,0 +1,83 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerCategory +@using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerCategoryService CustomerCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Category Details" : "Edit Category") + @(ReadOnly ? "Viewing category specification." : "Modify existing category information.") +
+
+
+ + + + + + Category Name + + + + Description + + + + Audit History + Created At@DateTimeExtensions.ToString(_model.CreatedAt) + Created By@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + Last Updated At@DateTimeExtensions.ToString(_model.UpdatedAt) + Last Updated By@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + @if (_processing) { + + Updating... + } else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateCustomerCategoryRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateCustomerCategoryValidator _validator = new(); + private UpdateCustomerCategoryRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = Data; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerCategoryService.UpdateCustomerCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer category updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryHandler.cs b/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryHandler.cs new file mode 100644 index 0000000..6b23ec0 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class CreateCustomerCategoryRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateCustomerCategoryResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCustomerCategoryCommand(CreateCustomerCategoryRequest Data) : IRequest; + +public class CreateCustomerCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCustomerCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCustomerCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.CustomerCategory + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer Category", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.CustomerCategory + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.CustomerCategory.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCustomerCategoryResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryValidator.cs b/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryValidator.cs new file mode 100644 index 0000000..bc925c0 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/CreateCustomerCategoryValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class CreateCustomerCategoryValidator : AbstractValidator +{ + public CreateCustomerCategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/DeleteCustomerCategoryByIdHandler.cs b/Features/Thirdparty/CustomerCategory/Cqrs/DeleteCustomerCategoryByIdHandler.cs new file mode 100644 index 0000000..42e3a2e --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/DeleteCustomerCategoryByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public record DeleteCustomerCategoryByIdRequest(string Id); + +public record DeleteCustomerCategoryByIdCommand(DeleteCustomerCategoryByIdRequest Data) : IRequest; + +public class DeleteCustomerCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCustomerCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCustomerCategoryByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.CustomerCategory.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryByIdHandler.cs b/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryByIdHandler.cs new file mode 100644 index 0000000..06f53c4 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class GetCustomerCategoryByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCustomerCategoryByIdQuery(string Id) : IRequest; + +public class GetCustomerCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCustomerCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCustomerCategoryByIdQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerCategory + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCustomerCategoryByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryListHandler.cs b/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryListHandler.cs new file mode 100644 index 0000000..6db98fe --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/GetCustomerCategoryListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class GetCustomerCategoryListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCustomerCategoryListQuery() : IRequest>; + +public class GetCustomerCategoryListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCustomerCategoryListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCustomerCategoryListQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerCategory + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetCustomerCategoryListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryHandler.cs b/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryHandler.cs new file mode 100644 index 0000000..f5c2943 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class UpdateCustomerCategoryRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCustomerCategoryResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCustomerCategoryCommand(UpdateCustomerCategoryRequest Data) : IRequest; + +public class UpdateCustomerCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCustomerCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCustomerCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.CustomerCategory + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer Category", request.Data.Name ?? string.Empty); + } + + var entity = await _context.CustomerCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCustomerCategoryResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCustomerCategoryResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryValidator.cs b/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryValidator.cs new file mode 100644 index 0000000..29f409d --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/Cqrs/UpdateCustomerCategoryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; + +public class UpdateCustomerCategoryValidator : AbstractValidator +{ + public UpdateCustomerCategoryValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/CustomerCategoryEndpoint.cs b/Features/Thirdparty/CustomerCategory/CustomerCategoryEndpoint.cs new file mode 100644 index 0000000..40bb3e6 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/CustomerCategoryEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.CustomerCategory; + +public static class CustomerCategoryEndpoint +{ + public static void MapCustomerCategoryEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/customer-category").WithTags("CustomerCategories") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerCategoryListQuery()); + + return result.ToApiResponse("Customer category list retrieved successfully"); + }) + .WithName("GetCustomerCategoryList") + .WithTags("CustomerCategories"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerCategoryByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Customer category detail retrieved successfully" + : $"Customer category with ID {id} not found"); + }) + .WithName("GetCustomerCategoryById") + .WithTags("CustomerCategories"); + + group.MapPost("/", async (CreateCustomerCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCustomerCategoryCommand(request)); + + return result.ToApiResponse("Customer category has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCustomerCategory") + .WithTags("CustomerCategories"); + + group.MapPost("/update", async (UpdateCustomerCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCustomerCategoryCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The customer category data could not be found."); + } + + return result.ToApiResponse("Customer category has been updated successfully"); + }) + .WithName("UpdateCustomerCategory") + .WithTags("CustomerCategories"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCustomerCategoryByIdCommand(new DeleteCustomerCategoryByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The customer category data could not be found."); + } + + return true.ToApiResponse("Customer category has been deleted successfully"); + }) + .WithName("DeleteCustomerCategoryById") + .WithTags("CustomerCategories"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerCategory/CustomerCategoryService.cs b/Features/Thirdparty/CustomerCategory/CustomerCategoryService.cs new file mode 100644 index 0000000..9235b16 --- /dev/null +++ b/Features/Thirdparty/CustomerCategory/CustomerCategoryService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.CustomerCategory.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.CustomerCategory; + +public class CustomerCategoryService : BaseService +{ + private readonly RestClient _client; + + public CustomerCategoryService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCustomerCategoryListAsync() + { + var request = new RestRequest("api/customer-category", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCustomerCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/customer-category/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCustomerCategoryAsync(CreateCustomerCategoryRequest data) + { + var request = new RestRequest("api/customer-category", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCustomerCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/customer-category/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCustomerCategoryAsync(UpdateCustomerCategoryRequest data) + { + var request = new RestRequest("api/customer-category/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Components/CustomerContactPage.razor b/Features/Thirdparty/CustomerContact/Components/CustomerContactPage.razor new file mode 100644 index 0000000..f0021ef --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Components/CustomerContactPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/customer-contact" +@using Indotalent.Features.Thirdparty.CustomerContact +@using Indotalent.Features.Thirdparty.CustomerContact.Cqrs +@using Indotalent.Features.Thirdparty.CustomerContact.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CustomerContactCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CustomerContactUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_CustomerContactDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCustomerContactRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateCustomerContactRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Components/_CustomerContactCreateForm.razor b/Features/Thirdparty/CustomerContact/Components/_CustomerContactCreateForm.razor new file mode 100644 index 0000000..7ee2c02 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Components/_CustomerContactCreateForm.razor @@ -0,0 +1,101 @@ +@using Indotalent.Features.Thirdparty.CustomerContact +@using Indotalent.Features.Thirdparty.CustomerContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerContactService CustomerContactService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Contact + Register a new contact person for your customers. +
+
+
+ + + + + + Contact Information + + + + Contact Name + + + + Customer + + @foreach (var item in _lookupData.Customers) + { + @item.Name + } + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description / Notes + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Contact + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateCustomerContactValidator _validator = new(); + private CreateCustomerContactRequest _model = new(); + private LookupCustomerContactResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await CustomerContactService.GetCustomerContactLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerContactService.CreateCustomerContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Contact created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Components/_CustomerContactDataTable.razor b/Features/Thirdparty/CustomerContact/Components/_CustomerContactDataTable.razor new file mode 100644 index 0000000..cbe0929 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Components/_CustomerContactDataTable.razor @@ -0,0 +1,280 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerContact +@using Indotalent.Features.Thirdparty.CustomerContact.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CustomerContactService CustomerContactService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Customer Contact + Manage individual contact persons for your customers. +
+
+ + / + Third Party + / + Customer Contact +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedContact != null) + { + View + Edit + Remove + + } + else + { + + Add New Contact + + } +
+
+ + + + + + Contact Name + + + Customer + + Job Title + Email / Phone + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.CustomerName + @context.JobTitle + +
+ @context.EmailAddress + @context.PhoneNumber +
+
+
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _contacts = new(); + private GetCustomerContactListResponse? _selectedContact; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedContact = null; + StateHasChanged(); + try + { + var response = await CustomerContactService.GetCustomerContactListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _contacts = response.Value ?? new List(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _contacts; + return _contacts.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmailAddress?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedContact = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("CustomerContacts"); + worksheet.Cell(1, 1).Value = "Contact Name"; + worksheet.Cell(1, 2).Value = "Job Title"; + worksheet.Cell(1, 3).Value = "Customer"; + worksheet.Cell(1, 4).Value = "Email"; + worksheet.Cell(1, 5).Value = "Phone"; + worksheet.Range(1, 1, 1, 5).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.JobTitle; + worksheet.Cell(currentRow, 3).Value = item.CustomerName; + worksheet.Cell(currentRow, 4).Value = item.EmailAddress; + worksheet.Cell(currentRow, 5).Value = item.PhoneNumber; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "CustomerContact_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedContact = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedContact = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedContact != null) { var req = await MapToUpdateRequest(_selectedContact.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedContact != null) { var req = await MapToUpdateRequest(_selectedContact.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await CustomerContactService.GetCustomerContactByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateCustomerContactRequest { Id = d.Id, Name = d.Name, JobTitle = d.JobTitle, PhoneNumber = d.PhoneNumber, EmailAddress = d.EmailAddress, Description = d.Description, CustomerId = d.CustomerId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedContact == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedContact.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await CustomerContactService.DeleteCustomerContactByIdAsync(_selectedContact.Id); + if (success) { _selectedContact = null; await LoadData(); Snackbar.Add("Contact deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Components/_CustomerContactUpdateForm.razor b/Features/Thirdparty/CustomerContact/Components/_CustomerContactUpdateForm.razor new file mode 100644 index 0000000..7cc2d1b --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Components/_CustomerContactUpdateForm.razor @@ -0,0 +1,143 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerContact +@using Indotalent.Features.Thirdparty.CustomerContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerContactService CustomerContactService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Contact Details" : "Edit Contact") + @(ReadOnly ? "Viewing contact person profile." : "Modify contact details.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Contact Information + + + + Contact Name + + + + Customer + + @foreach (var item in _lookupData.Customers) + { + @item.Name + } + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description / Notes + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateCustomerContactRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateCustomerContactValidator _validator = new(); + private UpdateCustomerContactRequest _model = new(); + private LookupCustomerContactResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await CustomerContactService.GetCustomerContactLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + _model = Data; + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerContactService.UpdateCustomerContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Contact updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactHandler.cs new file mode 100644 index 0000000..5299d12 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class CreateCustomerContactRequest +{ + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } +} + +public class CreateCustomerContactResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCustomerContactCommand(CreateCustomerContactRequest Data) : IRequest; + +public class CreateCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCustomerContactCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.CustomerContact); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.CustomerContact + { + AutoNumber = autoNo, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description, + CustomerId = request.Data.CustomerId + }; + + _context.CustomerContact.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCustomerContactResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactValidator.cs b/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactValidator.cs new file mode 100644 index 0000000..f914b95 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/CreateCustomerContactValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class CreateCustomerContactValidator : AbstractValidator +{ + public CreateCustomerContactValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Contact Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CustomerId) + .NotEmpty().WithMessage("Customer is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/DeleteCustomerContactByIdHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/DeleteCustomerContactByIdHandler.cs new file mode 100644 index 0000000..325fd98 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/DeleteCustomerContactByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public record DeleteCustomerContactByIdRequest(string Id); + +public record DeleteCustomerContactByIdCommand(DeleteCustomerContactByIdRequest Data) : IRequest; + +public class DeleteCustomerContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCustomerContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCustomerContactByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.CustomerContact.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactByIdHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactByIdHandler.cs new file mode 100644 index 0000000..3c8c704 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class GetCustomerContactByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCustomerContactByIdQuery(string Id) : IRequest; + +public class GetCustomerContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCustomerContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCustomerContactByIdQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerContact + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCustomerContactByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + Description = x.Description, + CustomerId = x.CustomerId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactListHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactListHandler.cs new file mode 100644 index 0000000..a881cb2 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/GetCustomerContactListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class GetCustomerContactListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? CustomerName { get; set; } +} + +public record GetCustomerContactListQuery() : IRequest>; + +public class GetCustomerContactListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCustomerContactListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCustomerContactListQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerContact + .AsNoTracking() + .Include(x => x.Customer) + .OrderBy(x => x.Name) + .Select(x => new GetCustomerContactListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + CustomerName = x.Customer != null ? x.Customer.Name : string.Empty + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/LookupCustomerContactHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/LookupCustomerContactHandler.cs new file mode 100644 index 0000000..2a43ac6 --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/LookupCustomerContactHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class LookupCustomerContactResponse +{ + public List Customers { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupCustomerContactQuery() : IRequest; + +public class LookupCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupCustomerContactQuery request, CancellationToken cancellationToken) + { + var result = new LookupCustomerContactResponse(); + + result.Customers = await _context.Customer + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactHandler.cs b/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactHandler.cs new file mode 100644 index 0000000..5fa9f3b --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class UpdateCustomerContactRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCustomerContactResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCustomerContactCommand(UpdateCustomerContactRequest Data) : IRequest; + +public class UpdateCustomerContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCustomerContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCustomerContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCustomerContactResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + entity.CustomerId = request.Data.CustomerId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCustomerContactResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactValidator.cs b/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactValidator.cs new file mode 100644 index 0000000..c28c7cc --- /dev/null +++ b/Features/Thirdparty/CustomerContact/Cqrs/UpdateCustomerContactValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerContact.Cqrs; + +public class UpdateCustomerContactValidator : AbstractValidator +{ + public UpdateCustomerContactValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Contact Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CustomerId) + .NotEmpty().WithMessage("Customer is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/CustomerContactEndpoint.cs b/Features/Thirdparty/CustomerContact/CustomerContactEndpoint.cs new file mode 100644 index 0000000..e665bfa --- /dev/null +++ b/Features/Thirdparty/CustomerContact/CustomerContactEndpoint.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.CustomerContact.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.CustomerContact; + +public static class CustomerContactEndpoint +{ + public static void MapCustomerContactEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/customer-contact").WithTags("CustomerContacts") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerContactListQuery()); + + return result.ToApiResponse("Customer contact list retrieved successfully"); + }) + .WithName("GetCustomerContactList") + .WithTags("CustomerContacts"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerContactByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Customer contact detail retrieved successfully" + : $"Customer contact with ID {id} not found"); + }) + .WithName("GetCustomerContactById") + .WithTags("CustomerContacts"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupCustomerContactQuery()); + + return result.ToApiResponse("Customer contact lookup data retrieved successfully"); + }) + .WithName("GetCustomerContactLookup") + .WithTags("CustomerContacts"); + + group.MapPost("/", async (CreateCustomerContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCustomerContactCommand(request)); + + return result.ToApiResponse("Customer contact has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCustomerContact") + .WithTags("CustomerContacts"); + + group.MapPost("/update", async (UpdateCustomerContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCustomerContactCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The contact data could not be found."); + } + + return result.ToApiResponse("Customer contact has been updated successfully"); + }) + .WithName("UpdateCustomerContact") + .WithTags("CustomerContacts"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCustomerContactByIdCommand(new DeleteCustomerContactByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The contact data could not be found."); + } + + return true.ToApiResponse("Customer contact has been deleted successfully"); + }) + .WithName("DeleteCustomerContactById") + .WithTags("CustomerContacts"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerContact/CustomerContactService.cs b/Features/Thirdparty/CustomerContact/CustomerContactService.cs new file mode 100644 index 0000000..c7143ba --- /dev/null +++ b/Features/Thirdparty/CustomerContact/CustomerContactService.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.CustomerContact.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.CustomerContact; + +public class CustomerContactService : BaseService +{ + private readonly RestClient _client; + + public CustomerContactService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCustomerContactListAsync() + { + var request = new RestRequest("api/customer-contact", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCustomerContactByIdAsync(string id) + { + var request = new RestRequest($"api/customer-contact/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetCustomerContactLookupAsync() + { + var request = new RestRequest("api/customer-contact/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCustomerContactAsync(CreateCustomerContactRequest data) + { + var request = new RestRequest("api/customer-contact", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCustomerContactByIdAsync(string id) + { + var request = new RestRequest($"api/customer-contact/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCustomerContactAsync(UpdateCustomerContactRequest data) + { + var request = new RestRequest("api/customer-contact/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Components/CustomerGroupPage.razor b/Features/Thirdparty/CustomerGroup/Components/CustomerGroupPage.razor new file mode 100644 index 0000000..c1719c6 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Components/CustomerGroupPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/customer-group" +@using Indotalent.Features.Thirdparty.CustomerGroup +@using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs +@using Indotalent.Features.Thirdparty.CustomerGroup.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CustomerGroupCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CustomerGroupUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_CustomerGroupDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCustomerGroupRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateCustomerGroupRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupCreateForm.razor b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupCreateForm.razor new file mode 100644 index 0000000..44d687b --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupCreateForm.razor @@ -0,0 +1,68 @@ +@using Indotalent.Features.Thirdparty.CustomerGroup +@using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerGroupService CustomerGroupService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Customer Group + Create a new segment for your customer base. +
+
+
+ + + + + + Group Name + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Group + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateCustomerGroupValidator _validator = new(); + private CreateCustomerGroupRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerGroupService.CreateCustomerGroupAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer group created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupDataTable.razor b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupDataTable.razor new file mode 100644 index 0000000..3e5ffd8 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupDataTable.razor @@ -0,0 +1,278 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerGroup +@using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CustomerGroupService CustomerGroupService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Customer Group + Manage and categorize your customer segments. +
+
+ + / + Third Party + / + Customer Group +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedGroup != null) + { + View + Edit + Remove + + } + else + { + + Add New Group + + } +
+
+ + + + + + Group Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _groups = new(); + private GetCustomerGroupListResponse? _selectedGroup; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedGroup = null; + StateHasChanged(); + try + { + var response = await CustomerGroupService.GetCustomerGroupListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _groups = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _groups; + return _groups.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedGroup = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("CustomerGroups"); + worksheet.Cell(1, 1).Value = "Group Name"; + worksheet.Cell(1, 2).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "CustomerGroup_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedGroup = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedGroup = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedGroup != null) { var req = await MapToUpdateRequest(_selectedGroup.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedGroup != null) { var req = await MapToUpdateRequest(_selectedGroup.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await CustomerGroupService.GetCustomerGroupByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateCustomerGroupRequest { Id = d.Id, Name = d.Name, Description = d.Description, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedGroup == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedGroup.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await CustomerGroupService.DeleteCustomerGroupByIdAsync(_selectedGroup.Id); + if (isSuccess) { _selectedGroup = null; await LoadData(); Snackbar.Add("Group deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupUpdateForm.razor b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupUpdateForm.razor new file mode 100644 index 0000000..a441c96 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Components/_CustomerGroupUpdateForm.razor @@ -0,0 +1,83 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.CustomerGroup +@using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CustomerGroupService CustomerGroupService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Customer Group Details" : "Edit Customer Group") + @(ReadOnly ? "Viewing customer segment specification." : "Modify existing segment information.") +
+
+
+ + + + + + Group Name + + + + Description + + + + Audit History + Created At@DateTimeExtensions.ToString(_model.CreatedAt) + Created By@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + Last Updated At@DateTimeExtensions.ToString(_model.UpdatedAt) + Last Updated By@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + @if (_processing) { + + Updating... + } else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateCustomerGroupRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateCustomerGroupValidator _validator = new(); + private UpdateCustomerGroupRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = Data; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CustomerGroupService.UpdateCustomerGroupAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Customer group updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupHandler.cs b/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupHandler.cs new file mode 100644 index 0000000..5cf176f --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class CreateCustomerGroupRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateCustomerGroupResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCustomerGroupCommand(CreateCustomerGroupRequest Data) : IRequest; + +public class CreateCustomerGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCustomerGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCustomerGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.CustomerGroup + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer Group", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.CustomerGroup + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.CustomerGroup.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCustomerGroupResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupValidator.cs b/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupValidator.cs new file mode 100644 index 0000000..6fe34e2 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/CreateCustomerGroupValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class CreateCustomerGroupValidator : AbstractValidator +{ + public CreateCustomerGroupValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/DeleteCustomerGroupByIdHandler.cs b/Features/Thirdparty/CustomerGroup/Cqrs/DeleteCustomerGroupByIdHandler.cs new file mode 100644 index 0000000..d488b13 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/DeleteCustomerGroupByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public record DeleteCustomerGroupByIdRequest(string Id); + +public record DeleteCustomerGroupByIdCommand(DeleteCustomerGroupByIdRequest Data) : IRequest; + +public class DeleteCustomerGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCustomerGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCustomerGroupByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CustomerGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.CustomerGroup.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupByIdHandler.cs b/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupByIdHandler.cs new file mode 100644 index 0000000..14047c4 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class GetCustomerGroupByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCustomerGroupByIdQuery(string Id) : IRequest; + +public class GetCustomerGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCustomerGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCustomerGroupByIdQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerGroup + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCustomerGroupByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupListHandler.cs b/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupListHandler.cs new file mode 100644 index 0000000..361d046 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/GetCustomerGroupListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class GetCustomerGroupListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCustomerGroupListQuery() : IRequest>; + +public class GetCustomerGroupListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCustomerGroupListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCustomerGroupListQuery request, CancellationToken cancellationToken) + { + return await _context.CustomerGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetCustomerGroupListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupHandler.cs b/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupHandler.cs new file mode 100644 index 0000000..0751862 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class UpdateCustomerGroupRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCustomerGroupResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCustomerGroupCommand(UpdateCustomerGroupRequest Data) : IRequest; + +public class UpdateCustomerGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCustomerGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCustomerGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.CustomerGroup + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Customer Group", request.Data.Name ?? string.Empty); + } + + var entity = await _context.CustomerGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCustomerGroupResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCustomerGroupResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupValidator.cs b/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupValidator.cs new file mode 100644 index 0000000..36eab0a --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/Cqrs/UpdateCustomerGroupValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; + +public class UpdateCustomerGroupValidator : AbstractValidator +{ + public UpdateCustomerGroupValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/CustomerGroupEndpoint.cs b/Features/Thirdparty/CustomerGroup/CustomerGroupEndpoint.cs new file mode 100644 index 0000000..8b391d7 --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/CustomerGroupEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.CustomerGroup; + +public static class CustomerGroupEndpoint +{ + public static void MapCustomerGroupEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/customer-group").WithTags("CustomerGroups") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerGroupListQuery()); + + return result.ToApiResponse("Customer group list retrieved successfully"); + }) + .WithName("GetCustomerGroupList") + .WithTags("CustomerGroups"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCustomerGroupByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Customer group detail retrieved successfully" + : $"Customer group with ID {id} not found"); + }) + .WithName("GetCustomerGroupById") + .WithTags("CustomerGroups"); + + group.MapPost("/", async (CreateCustomerGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCustomerGroupCommand(request)); + + return result.ToApiResponse("Customer group has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCustomerGroup") + .WithTags("CustomerGroups"); + + group.MapPost("/update", async (UpdateCustomerGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCustomerGroupCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The customer group data could not be found."); + } + + return result.ToApiResponse("Customer group has been updated successfully"); + }) + .WithName("UpdateCustomerGroup") + .WithTags("CustomerGroups"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCustomerGroupByIdCommand(new DeleteCustomerGroupByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The customer group data could not be found."); + } + + return true.ToApiResponse("Customer group has been deleted successfully"); + }) + .WithName("DeleteCustomerGroupById") + .WithTags("CustomerGroups"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/CustomerGroup/CustomerGroupService.cs b/Features/Thirdparty/CustomerGroup/CustomerGroupService.cs new file mode 100644 index 0000000..7a38b8f --- /dev/null +++ b/Features/Thirdparty/CustomerGroup/CustomerGroupService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.CustomerGroup.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.CustomerGroup; + +public class CustomerGroupService : BaseService +{ + private readonly RestClient _client; + + public CustomerGroupService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCustomerGroupListAsync() + { + var request = new RestRequest("api/customer-group", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCustomerGroupByIdAsync(string id) + { + var request = new RestRequest($"api/customer-group/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCustomerGroupAsync(CreateCustomerGroupRequest data) + { + var request = new RestRequest("api/customer-group", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCustomerGroupByIdAsync(string id) + { + var request = new RestRequest($"api/customer-group/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCustomerGroupAsync(UpdateCustomerGroupRequest data) + { + var request = new RestRequest("api/customer-group/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/ThirdpartyPage.razor b/Features/Thirdparty/ThirdpartyPage.razor new file mode 100644 index 0000000..1a6fdb5 --- /dev/null +++ b/Features/Thirdparty/ThirdpartyPage.razor @@ -0,0 +1,123 @@ +@page "/thirdparty" +@using Indotalent.Features.Thirdparty.Customer.Components +@using Indotalent.Features.Thirdparty.CustomerCategory.Components +@using Indotalent.Features.Thirdparty.CustomerContact.Components +@using Indotalent.Features.Thirdparty.CustomerGroup.Components +@using Indotalent.Features.Thirdparty.Vendor.Components +@using Indotalent.Features.Thirdparty.VendorCategory.Components +@using Indotalent.Features.Thirdparty.VendorContact.Components +@using Indotalent.Features.Thirdparty.VendorGroup.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "customer-group" }, + { 1, "customer-category" }, + { 2, "customer" }, + { 3, "customer-contact" }, + { 4, "vendor-group" }, + { 5, "vendor-category" }, + { 6, "vendor" }, + { 7, "vendor-contact" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + _activeTabIndex = match.Value != null ? match.Key : 0; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/thirdparty?tab={tabName ?? "customer-group"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/VendorPage.razor b/Features/Thirdparty/Vendor/Components/VendorPage.razor new file mode 100644 index 0000000..aa97308 --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/VendorPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/vendor" +@using Indotalent.Features.Thirdparty.Vendor +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using Indotalent.Features.Thirdparty.Vendor.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_VendorCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_VendorUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_VendorDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateVendorRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateVendorRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorContactCreateForm.razor b/Features/Thirdparty/Vendor/Components/_VendorContactCreateForm.razor new file mode 100644 index 0000000..8d3857b --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorContactCreateForm.razor @@ -0,0 +1,76 @@ +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using MudBlazor +@inject VendorService VendorService +@inject ISnackbar Snackbar + + + + + + + Contact Name + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Contact + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string VendorId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateVendorContactRequest _model = new(); + private bool _processing = false; + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + _model.VendorId = VendorId; + try + { + var response = await VendorService.CreateVendorContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Contact added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorContactDataTable.razor b/Features/Thirdparty/Vendor/Components/_VendorContactDataTable.razor new file mode 100644 index 0000000..9a65be2 --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorContactDataTable.razor @@ -0,0 +1,101 @@ +@using Indotalent.Features.Thirdparty.Vendor +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using MudBlazor +@using Features.Root.Shared +@inject VendorService VendorService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Vendor Contacts + @if (!ReadOnly) + { + + Add Contact + + } +
+ + + + Name + Job Title + Contact Info + Description + @if (!ReadOnly) + { + Actions + } + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.JobTitle + +
+ @context.EmailAddress + @context.PhoneNumber +
+
+ @context.Description + @if (!ReadOnly) + { + + + + + } +
+
+
+ + + + +@code { + [Parameter] public string VendorId { get; set; } = string.Empty; + [Parameter] public List Contacts { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["VendorId"] = VendorId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_VendorContactCreateForm>("Add Vendor Contact", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(VendorContactItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_VendorContactUpdateForm>("Edit Vendor Contact", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(VendorContactItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove {item.Name} from contacts?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await VendorService.DeleteVendorContactAsync(item.Id!); + if (success) + { + Snackbar.Add("Contact removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorContactUpdateForm.razor b/Features/Thirdparty/Vendor/Components/_VendorContactUpdateForm.razor new file mode 100644 index 0000000..bf0ed3e --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorContactUpdateForm.razor @@ -0,0 +1,85 @@ +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using MudBlazor +@inject VendorService VendorService +@inject ISnackbar Snackbar + + + + + + + Contact Name + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public VendorContactItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateVendorContactRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Name = Data.Name; + _model.JobTitle = Data.JobTitle; + _model.PhoneNumber = Data.PhoneNumber; + _model.EmailAddress = Data.EmailAddress; + _model.Description = Data.Description; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorService.UpdateVendorContactAsync(_model); + await Task.Delay(500); + if (response != null && response.Value!.Success) + { + Snackbar.Add("Contact updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorCreateForm.razor b/Features/Thirdparty/Vendor/Components/_VendorCreateForm.razor new file mode 100644 index 0000000..e609856 --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorCreateForm.razor @@ -0,0 +1,173 @@ +@using Indotalent.Features.Thirdparty.Vendor +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorService VendorService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Vendor + Complete the profile and contact details of the vendor. +
+
+
+ + + + + + General Information + + + + Vendor Name + + + + Vendor Group + + @foreach (var item in _lookupData.VendorGroups) + { + @item.Name + } + + + + Vendor Category + + @foreach (var item in _lookupData.VendorCategories) + { + @item.Name + } + + + + Description + + + + + Address Information + + + + Street + + + + City + + + + State / Province + + + + Zip Code + + + + Country + + + + + Contact & Online + + + + Phone Number + + + + Fax Number + + + + WhatsApp + + + + Email Address + + + + Website + + + + + Social Media + + + + LinkedIn + + + + Facebook + + + + Instagram + + + + Twitter (X) + + + + TikTok + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Vendor + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateVendorValidator _validator = new(); + private CreateVendorRequest _model = new(); + private LookupVendorResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await VendorService.GetVendorLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorService.CreateVendorAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorDataTable.razor b/Features/Thirdparty/Vendor/Components/_VendorDataTable.razor new file mode 100644 index 0000000..37cbd37 --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorDataTable.razor @@ -0,0 +1,279 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.Vendor +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject VendorService VendorService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Vendor Management + Maintain your supplier database and contact information. +
+
+ + / + Third Party + / + Vendor +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedVendor != null) + { + View + Edit + Remove + + } + else + { + + Add New Vendor + + } +
+
+ + + + + + Vendor + + + Group + + Contact + Category + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.VendorGroupName + +
+ @context.PhoneNumber + @context.EmailAddress +
+
+ + @context.VendorCategoryName + +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _vendors = new(); + private GetVendorListResponse? _selectedVendor; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedVendor = null; + StateHasChanged(); + try + { + var response = await VendorService.GetVendorListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _vendors = response.Value ?? new List(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _vendors; + return _vendors.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedVendor = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Vendors"); + worksheet.Cell(1, 1).Value = "Vendor Name"; + worksheet.Cell(1, 2).Value = "Number"; + worksheet.Cell(1, 3).Value = "Phone"; + worksheet.Cell(1, 4).Value = "Email"; + worksheet.Range(1, 1, 1, 4).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.AutoNumber; + worksheet.Cell(currentRow, 3).Value = item.PhoneNumber; + worksheet.Cell(currentRow, 4).Value = item.EmailAddress; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Vendor_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedVendor = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedVendor = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedVendor != null) { var req = await MapToUpdateRequest(_selectedVendor.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedVendor != null) { var req = await MapToUpdateRequest(_selectedVendor.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await VendorService.GetVendorByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateVendorRequest { Id = d.Id, Name = d.Name, Description = d.Description, Street = d.Street, City = d.City, State = d.State, ZipCode = d.ZipCode, Country = d.Country, PhoneNumber = d.PhoneNumber, FaxNumber = d.FaxNumber, EmailAddress = d.EmailAddress, Website = d.Website, WhatsApp = d.WhatsApp, LinkedIn = d.LinkedIn, Facebook = d.Facebook, Instagram = d.Instagram, TwitterX = d.TwitterX, TikTok = d.TikTok, VendorGroupId = d.VendorGroupId, VendorCategoryId = d.VendorCategoryId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedVendor == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedVendor.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await VendorService.DeleteVendorByIdAsync(_selectedVendor.Id); + if (success) { _selectedVendor = null; await LoadData(); Snackbar.Add("Vendor deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Components/_VendorUpdateForm.razor b/Features/Thirdparty/Vendor/Components/_VendorUpdateForm.razor new file mode 100644 index 0000000..d264fd9 --- /dev/null +++ b/Features/Thirdparty/Vendor/Components/_VendorUpdateForm.razor @@ -0,0 +1,251 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.Vendor +@using Indotalent.Features.Thirdparty.Vendor.Cqrs +@using Indotalent.Features.Thirdparty.Vendor.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorService VendorService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Vendor Profile" : "Edit Vendor") + @(ReadOnly ? "Viewing detailed vendor records." : "Update vendor account information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + General Information + + + + Vendor Name + + + + Vendor Group + + @foreach (var item in _lookupData.VendorGroups) + { + @item.Name + } + + + + Vendor Category + + @foreach (var item in _lookupData.VendorCategories) + { + @item.Name + } + + + + Description + + + + + <_VendorContactDataTable Contacts="_model.Contacts" VendorId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshData" /> + + + + Address Information + + + + Street + + + + City + + + + State / Province + + + + Zip Code + + + + Country + + + + + Contact & Online + + + + Phone Number + + + + Fax Number + + + + WhatsApp + + + + Email Address + + + + Website + + + + + Social Media + + + + LinkedIn + + + + Facebook + + + + Instagram + + + + Twitter (X) + + + + TikTok + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateVendorRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateVendorValidator _validator = new(); + private GetVendorByIdResponse _model = new(); + private LookupVendorResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + await RefreshData(); + } + + private async Task RefreshData() + { + _isDataLoading = true; + try + { + var lookup = await VendorService.GetVendorLookupAsync(); + if (lookup != null && lookup.IsSuccess) { _lookupData = lookup.Value!; } + + var vendor = await VendorService.GetVendorByIdAsync(Data.Id!); + if (vendor != null && vendor.IsSuccess) { _model = vendor.Value!; } + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var updateRequest = new UpdateVendorRequest + { + Id = _model.Id, + Name = _model.Name, + Description = _model.Description, + Street = _model.Street, + City = _model.City, + State = _model.State, + ZipCode = _model.ZipCode, + Country = _model.Country, + PhoneNumber = _model.PhoneNumber, + FaxNumber = _model.FaxNumber, + EmailAddress = _model.EmailAddress, + Website = _model.Website, + WhatsApp = _model.WhatsApp, + LinkedIn = _model.LinkedIn, + Facebook = _model.Facebook, + Instagram = _model.Instagram, + TwitterX = _model.TwitterX, + TikTok = _model.TikTok, + VendorGroupId = _model.VendorGroupId, + VendorCategoryId = _model.VendorCategoryId + }; + + var response = await VendorService.UpdateVendorAsync(updateRequest); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/CreateVendorContactHandler.cs b/Features/Thirdparty/Vendor/Cqrs/CreateVendorContactHandler.cs new file mode 100644 index 0000000..201181d --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/CreateVendorContactHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class CreateVendorContactRequest +{ + public string? VendorId { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class CreateVendorContactResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateVendorContactCommand(CreateVendorContactRequest Data) : IRequest; + +public class CreateVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateVendorContactCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.VendorContact); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.VendorContact + { + AutoNumber = autoNo, + VendorId = request.Data.VendorId, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description + }; + + _context.VendorContact.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateVendorContactResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/CreateVendorHandler.cs b/Features/Thirdparty/Vendor/Cqrs/CreateVendorHandler.cs new file mode 100644 index 0000000..fba0e47 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/CreateVendorHandler.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class CreateVendorRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? VendorGroupId { get; set; } + public string? VendorCategoryId { get; set; } +} + +public class CreateVendorResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateVendorCommand(CreateVendorRequest Data) : IRequest; + +public class CreateVendorHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateVendorHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateVendorCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Vendor + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor", request.Data.Name ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Vendor); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Vendor + { + AutoNumber = autoNo, + Name = request.Data.Name, + Description = request.Data.Description, + Street = request.Data.Street, + City = request.Data.City, + State = request.Data.State, + ZipCode = request.Data.ZipCode, + Country = request.Data.Country, + PhoneNumber = request.Data.PhoneNumber, + FaxNumber = request.Data.FaxNumber, + EmailAddress = request.Data.EmailAddress, + Website = request.Data.Website, + WhatsApp = request.Data.WhatsApp, + LinkedIn = request.Data.LinkedIn, + Facebook = request.Data.Facebook, + Instagram = request.Data.Instagram, + TwitterX = request.Data.TwitterX, + TikTok = request.Data.TikTok, + VendorGroupId = request.Data.VendorGroupId, + VendorCategoryId = request.Data.VendorCategoryId + }; + + _context.Vendor.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateVendorResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/CreateVendorValidator.cs b/Features/Thirdparty/Vendor/Cqrs/CreateVendorValidator.cs new file mode 100644 index 0000000..9c81ca8 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/CreateVendorValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class CreateVendorValidator : AbstractValidator +{ + public CreateVendorValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Vendor Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.VendorGroupId) + .NotEmpty().WithMessage("Vendor Group is required"); + + RuleFor(x => x.VendorCategoryId) + .NotEmpty().WithMessage("Vendor Category is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/DeleteVendorByIdHandler.cs b/Features/Thirdparty/Vendor/Cqrs/DeleteVendorByIdHandler.cs new file mode 100644 index 0000000..3b905e1 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/DeleteVendorByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public record DeleteVendorByIdRequest(string Id); + +public record DeleteVendorByIdCommand(DeleteVendorByIdRequest Data) : IRequest; + +public class DeleteVendorByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteVendorByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteVendorByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Vendor + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Vendor.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/DeleteVendorContactHandler.cs b/Features/Thirdparty/Vendor/Cqrs/DeleteVendorContactHandler.cs new file mode 100644 index 0000000..fc28d6a --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/DeleteVendorContactHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public record DeleteVendorContactCommand(string Id) : IRequest; + +public class DeleteVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteVendorContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorContact + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.VendorContact.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/GetVendorByIdHandler.cs b/Features/Thirdparty/Vendor/Cqrs/GetVendorByIdHandler.cs new file mode 100644 index 0000000..4d47443 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/GetVendorByIdHandler.cs @@ -0,0 +1,104 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class VendorContactItemResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class GetVendorByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? VendorGroupId { get; set; } + public string? VendorCategoryId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Contacts { get; set; } = new(); +} + +public record GetVendorByIdQuery(string Id) : IRequest; + +public class GetVendorByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetVendorByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetVendorByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Vendor + .AsNoTracking() + .Include(x => x.VendorContactList) + .Where(x => x.Id == request.Id) + .Select(x => new GetVendorByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + Description = x.Description, + Street = x.Street, + City = x.City, + State = x.State, + ZipCode = x.ZipCode, + Country = x.Country, + PhoneNumber = x.PhoneNumber, + FaxNumber = x.FaxNumber, + EmailAddress = x.EmailAddress, + Website = x.Website, + WhatsApp = x.WhatsApp, + LinkedIn = x.LinkedIn, + Facebook = x.Facebook, + Instagram = x.Instagram, + TwitterX = x.TwitterX, + TikTok = x.TikTok, + VendorGroupId = x.VendorGroupId, + VendorCategoryId = x.VendorCategoryId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Contacts = x.VendorContactList + .OrderBy(c => c.Name) + .Select(c => new VendorContactItemResponse + { + Id = c.Id, + Name = c.Name, + AutoNumber = c.AutoNumber, + JobTitle = c.JobTitle, + PhoneNumber = c.PhoneNumber, + EmailAddress = c.EmailAddress, + Description = c.Description + }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/GetVendorListHandler.cs b/Features/Thirdparty/Vendor/Cqrs/GetVendorListHandler.cs new file mode 100644 index 0000000..0691d8e --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/GetVendorListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class GetVendorListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? VendorGroupName { get; set; } + public string? VendorCategoryName { get; set; } +} + +public record GetVendorListQuery() : IRequest>; + +public class GetVendorListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetVendorListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetVendorListQuery request, CancellationToken cancellationToken) + { + return await _context.Vendor + .AsNoTracking() + .Include(x => x.VendorGroup) + .Include(x => x.VendorCategory) + .OrderBy(x => x.Name) + .Select(x => new GetVendorListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + VendorGroupName = x.VendorGroup != null ? x.VendorGroup.Name : string.Empty, + VendorCategoryName = x.VendorCategory != null ? x.VendorCategory.Name : string.Empty + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/LookupVendorHandler.cs b/Features/Thirdparty/Vendor/Cqrs/LookupVendorHandler.cs new file mode 100644 index 0000000..ed92f70 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/LookupVendorHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class LookupVendorResponse +{ + public List VendorGroups { get; set; } = new(); + public List VendorCategories { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupVendorQuery() : IRequest; + +public class LookupVendorHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupVendorHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupVendorQuery request, CancellationToken cancellationToken) + { + var result = new LookupVendorResponse(); + + result.VendorGroups = await _context.VendorGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + result.VendorCategories = await _context.VendorCategory + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/UpdateVendorContactHandler.cs b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorContactHandler.cs new file mode 100644 index 0000000..e8ecd06 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorContactHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class UpdateVendorContactRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class UpdateVendorContactResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateVendorContactCommand(UpdateVendorContactRequest Data) : IRequest; + +public class UpdateVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateVendorContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateVendorContactResponse + { + Id = request.Data.Id, + Success = false + }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateVendorContactResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/UpdateVendorHandler.cs b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorHandler.cs new file mode 100644 index 0000000..e3c5cfd --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorHandler.cs @@ -0,0 +1,96 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class UpdateVendorRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? TwitterX { get; set; } + public string? TikTok { get; set; } + public string? VendorGroupId { get; set; } + public string? VendorCategoryId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateVendorResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateVendorCommand(UpdateVendorRequest Data) : IRequest; + +public class UpdateVendorHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateVendorHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateVendorCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Vendor + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor", request.Data.Name ?? string.Empty); + } + + var entity = await _context.Vendor + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateVendorResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Street = request.Data.Street; + entity.City = request.Data.City; + entity.State = request.Data.State; + entity.ZipCode = request.Data.ZipCode; + entity.Country = request.Data.Country; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.FaxNumber = request.Data.FaxNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Website = request.Data.Website; + entity.WhatsApp = request.Data.WhatsApp; + entity.LinkedIn = request.Data.LinkedIn; + entity.Facebook = request.Data.Facebook; + entity.Instagram = request.Data.Instagram; + entity.TwitterX = request.Data.TwitterX; + entity.TikTok = request.Data.TikTok; + entity.VendorGroupId = request.Data.VendorGroupId; + entity.VendorCategoryId = request.Data.VendorCategoryId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateVendorResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/Cqrs/UpdateVendorValidator.cs b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorValidator.cs new file mode 100644 index 0000000..0d2d466 --- /dev/null +++ b/Features/Thirdparty/Vendor/Cqrs/UpdateVendorValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.Vendor.Cqrs; + +public class UpdateVendorValidator : AbstractValidator +{ + public UpdateVendorValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Vendor Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.VendorGroupId) + .NotEmpty().WithMessage("Vendor Group is required"); + + RuleFor(x => x.VendorCategoryId) + .NotEmpty().WithMessage("Vendor Category is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/VendorEndpoint.cs b/Features/Thirdparty/Vendor/VendorEndpoint.cs new file mode 100644 index 0000000..0873988 --- /dev/null +++ b/Features/Thirdparty/Vendor/VendorEndpoint.cs @@ -0,0 +1,106 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.Vendor.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.Vendor; + +public static class VendorEndpoint +{ + public static void MapVendorEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/vendor").WithTags("Vendors") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetVendorListQuery()); + return result.ToApiResponse("Vendor list retrieved successfully"); + }) + .WithName("GetVendorList") + .WithTags("Vendors"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetVendorByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Vendor detail retrieved successfully" + : $"Vendor with ID {id} not found"); + }) + .WithName("GetVendorById") + .WithTags("Vendors"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupVendorQuery()); + return result.ToApiResponse("Vendor lookup data retrieved successfully"); + }) + .WithName("GetVendorLookup") + .WithTags("Vendors"); + + group.MapPost("/", async (CreateVendorRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateVendorCommand(request)); + return result.ToApiResponse("Vendor has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateVendor") + .WithTags("Vendors"); + + group.MapPost("/update", async (UpdateVendorRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateVendorCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The vendor data could not be found."); + } + return result.ToApiResponse("Vendor has been updated successfully"); + }) + .WithName("UpdateVendor") + .WithTags("Vendors"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteVendorByIdCommand(new DeleteVendorByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The vendor data could not be found."); + } + return true.ToApiResponse("Vendor has been deleted successfully"); + }) + .WithName("DeleteVendorById") + .WithTags("Vendors"); + + group.MapPost("/vendor-contact", async (CreateVendorContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateVendorContactCommand(request)); + return result.ToApiResponse("Vendor contact has been added successfully"); + }) + .WithName("CreateVendorContactChild") + .WithTags("Vendors"); + + group.MapPost("/vendor-contact/update", async (UpdateVendorContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateVendorContactCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Vendor contact not found."); + } + return result.ToApiResponse("Vendor contact has been updated successfully"); + }) + .WithName("UpdateVendorContactChild") + .WithTags("Vendors"); + + group.MapPost("/vendor-contact/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteVendorContactCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Vendor contact not found."); + } + return true.ToApiResponse("Vendor contact has been removed successfully"); + }) + .WithName("DeleteVendorContactChild") + .WithTags("Vendors"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/Vendor/VendorService.cs b/Features/Thirdparty/Vendor/VendorService.cs new file mode 100644 index 0000000..d3df1e3 --- /dev/null +++ b/Features/Thirdparty/Vendor/VendorService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.Vendor.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.Vendor; + +public class VendorService : BaseService +{ + private readonly RestClient _client; + + public VendorService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetVendorListAsync() + { + var request = new RestRequest("api/vendor", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetVendorByIdAsync(string id) + { + var request = new RestRequest($"api/vendor/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetVendorLookupAsync() + { + var request = new RestRequest("api/vendor/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateVendorAsync(CreateVendorRequest data) + { + var request = new RestRequest("api/vendor", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteVendorByIdAsync(string id) + { + var request = new RestRequest($"api/vendor/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateVendorAsync(UpdateVendorRequest data) + { + var request = new RestRequest("api/vendor/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateVendorContactAsync(CreateVendorContactRequest data) + { + var request = new RestRequest("api/vendor/vendor-contact", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateVendorContactAsync(UpdateVendorContactRequest data) + { + var request = new RestRequest("api/vendor/vendor-contact/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteVendorContactAsync(string id) + { + var request = new RestRequest($"api/vendor/vendor-contact/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Components/VendorCategoryPage.razor b/Features/Thirdparty/VendorCategory/Components/VendorCategoryPage.razor new file mode 100644 index 0000000..01b4461 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Components/VendorCategoryPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/vendor-category" +@using Indotalent.Features.Thirdparty.VendorCategory +@using Indotalent.Features.Thirdparty.VendorCategory.Cqrs +@using Indotalent.Features.Thirdparty.VendorCategory.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_VendorCategoryCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_VendorCategoryUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_VendorCategoryDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateVendorCategoryRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateVendorCategoryRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Components/_VendorCategoryCreateForm.razor b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryCreateForm.razor new file mode 100644 index 0000000..6ec881e --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryCreateForm.razor @@ -0,0 +1,72 @@ +@using Indotalent.Features.Thirdparty.VendorCategory +@using Indotalent.Features.Thirdparty.VendorCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorCategoryService VendorCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Vendor Category + Establish new classification for your vendors. +
+
+
+ + + + + + General Information + + + + Category Name + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Category + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateVendorCategoryValidator _validator = new(); + private CreateVendorCategoryRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorCategoryService.CreateVendorCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor category created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Components/_VendorCategoryDataTable.razor b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryDataTable.razor new file mode 100644 index 0000000..c4404ec --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryDataTable.razor @@ -0,0 +1,281 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorCategory +@using Indotalent.Features.Thirdparty.VendorCategory.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject VendorCategoryService VendorCategoryService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Vendor Category + Manage and classify your vendor segments. +
+
+ + / + Third Party + / + Vendor Category +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCategory != null) + { + View + Edit + Remove + + } + else + { + + Add New Category + + } +
+
+ + + + + + Category Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + +
+ @context.Name +
+
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _categories = new(); + private GetVendorCategoryListResponse? _selectedCategory; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCategory = null; + StateHasChanged(); + try + { + var response = await VendorCategoryService.GetVendorCategoryListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _categories = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _categories; + return _categories.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedCategory = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("VendorCategories"); + worksheet.Cell(1, 1).Value = "Category Name"; + worksheet.Cell(1, 2).Value = "Auto Number"; + worksheet.Cell(1, 3).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 3); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Description; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "VendorCategory_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedCategory = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedCategory = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedCategory != null) { var req = await MapToUpdateRequest(_selectedCategory.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedCategory != null) { var req = await MapToUpdateRequest(_selectedCategory.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await VendorCategoryService.GetVendorCategoryByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateVendorCategoryRequest { Id = d.Id, Name = d.Name, Description = d.Description, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCategory == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCategory.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await VendorCategoryService.DeleteVendorCategoryByIdAsync(_selectedCategory.Id); + if (isSuccess) { _selectedCategory = null; await LoadData(); Snackbar.Add("Category deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Components/_VendorCategoryUpdateForm.razor b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryUpdateForm.razor new file mode 100644 index 0000000..ce0a354 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Components/_VendorCategoryUpdateForm.razor @@ -0,0 +1,87 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorCategory +@using Indotalent.Features.Thirdparty.VendorCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorCategoryService VendorCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Category Details" : "Edit Category") + @(ReadOnly ? "Viewing vendor classification details." : "Modify existing category information.") +
+
+
+ + + + + + General Information + + + + Category Name + + + + Description + + + + Audit History + Created At@DateTimeExtensions.ToString(_model.CreatedAt) + Created By@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + Last Updated At@DateTimeExtensions.ToString(_model.UpdatedAt) + Last Updated By@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + @if (_processing) { + + Updating... + } else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateVendorCategoryRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateVendorCategoryValidator _validator = new(); + private UpdateVendorCategoryRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = Data; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorCategoryService.UpdateVendorCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor category updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryHandler.cs b/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryHandler.cs new file mode 100644 index 0000000..85b65a8 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class CreateVendorCategoryRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateVendorCategoryResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateVendorCategoryCommand(CreateVendorCategoryRequest Data) : IRequest; + +public class CreateVendorCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateVendorCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateVendorCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.VendorCategory + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor Category", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.VendorCategory + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.VendorCategory.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateVendorCategoryResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryValidator.cs b/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryValidator.cs new file mode 100644 index 0000000..e45c82b --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/CreateVendorCategoryValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class CreateVendorCategoryValidator : AbstractValidator +{ + public CreateVendorCategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/DeleteVendorCategoryByIdHandler.cs b/Features/Thirdparty/VendorCategory/Cqrs/DeleteVendorCategoryByIdHandler.cs new file mode 100644 index 0000000..dfc4366 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/DeleteVendorCategoryByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public record DeleteVendorCategoryByIdRequest(string Id); + +public record DeleteVendorCategoryByIdCommand(DeleteVendorCategoryByIdRequest Data) : IRequest; + +public class DeleteVendorCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteVendorCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteVendorCategoryByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.VendorCategory.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryByIdHandler.cs b/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryByIdHandler.cs new file mode 100644 index 0000000..ca8e58c --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class GetVendorCategoryByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetVendorCategoryByIdQuery(string Id) : IRequest; + +public class GetVendorCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetVendorCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetVendorCategoryByIdQuery request, CancellationToken cancellationToken) + { + return await _context.VendorCategory + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetVendorCategoryByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryListHandler.cs b/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryListHandler.cs new file mode 100644 index 0000000..23003a6 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/GetVendorCategoryListHandler.cs @@ -0,0 +1,35 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class GetVendorCategoryListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + +public record GetVendorCategoryListQuery() : IRequest>; + +public class GetVendorCategoryListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetVendorCategoryListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetVendorCategoryListQuery request, CancellationToken cancellationToken) + { + return await _context.VendorCategory + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetVendorCategoryListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryHandler.cs b/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryHandler.cs new file mode 100644 index 0000000..e4e4704 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class UpdateVendorCategoryRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateVendorCategoryResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateVendorCategoryCommand(UpdateVendorCategoryRequest Data) : IRequest; + +public class UpdateVendorCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateVendorCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateVendorCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.VendorCategory + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor Category", request.Data.Name ?? string.Empty); + } + + var entity = await _context.VendorCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateVendorCategoryResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateVendorCategoryResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryValidator.cs b/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryValidator.cs new file mode 100644 index 0000000..b1af0cf --- /dev/null +++ b/Features/Thirdparty/VendorCategory/Cqrs/UpdateVendorCategoryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorCategory.Cqrs; + +public class UpdateVendorCategoryValidator : AbstractValidator +{ + public UpdateVendorCategoryValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/VendorCategoryEndpoint.cs b/Features/Thirdparty/VendorCategory/VendorCategoryEndpoint.cs new file mode 100644 index 0000000..ce848d2 --- /dev/null +++ b/Features/Thirdparty/VendorCategory/VendorCategoryEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.VendorCategory.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.VendorCategory; + +public static class VendorCategoryEndpoint +{ + public static void MapVendorCategoryEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/vendor-category").WithTags("VendorCategories") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetVendorCategoryListQuery()); + + return result.ToApiResponse("Vendor category list retrieved successfully"); + }) + .WithName("GetVendorCategoryList") + .WithTags("VendorCategories"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetVendorCategoryByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Vendor category detail retrieved successfully" + : $"Vendor category with ID {id} not found"); + }) + .WithName("GetVendorCategoryById") + .WithTags("VendorCategories"); + + group.MapPost("/", async (CreateVendorCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateVendorCategoryCommand(request)); + + return result.ToApiResponse("Vendor category has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateVendorCategory") + .WithTags("VendorCategories"); + + group.MapPost("/update", async (UpdateVendorCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateVendorCategoryCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The vendor category data could not be found."); + } + + return result.ToApiResponse("Vendor category has been updated successfully"); + }) + .WithName("UpdateVendorCategory") + .WithTags("VendorCategories"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteVendorCategoryByIdCommand(new DeleteVendorCategoryByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The vendor category data could not be found."); + } + + return true.ToApiResponse("Vendor category has been deleted successfully"); + }) + .WithName("DeleteVendorCategoryById") + .WithTags("VendorCategories"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorCategory/VendorCategoryService.cs b/Features/Thirdparty/VendorCategory/VendorCategoryService.cs new file mode 100644 index 0000000..69e94de --- /dev/null +++ b/Features/Thirdparty/VendorCategory/VendorCategoryService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.VendorCategory.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.VendorCategory; + +public class VendorCategoryService : BaseService +{ + private readonly RestClient _client; + + public VendorCategoryService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetVendorCategoryListAsync() + { + var request = new RestRequest("api/vendor-category", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetVendorCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-category/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateVendorCategoryAsync(CreateVendorCategoryRequest data) + { + var request = new RestRequest("api/vendor-category", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteVendorCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-category/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateVendorCategoryAsync(UpdateVendorCategoryRequest data) + { + var request = new RestRequest("api/vendor-category/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Components/VendorContactPage.razor b/Features/Thirdparty/VendorContact/Components/VendorContactPage.razor new file mode 100644 index 0000000..6f5ac40 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Components/VendorContactPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/vendor-contact" +@using Indotalent.Features.Thirdparty.VendorContact +@using Indotalent.Features.Thirdparty.VendorContact.Cqrs +@using Indotalent.Features.Thirdparty.VendorContact.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_VendorContactCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_VendorContactUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_VendorContactDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateVendorContactRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateVendorContactRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Components/_VendorContactCreateForm.razor b/Features/Thirdparty/VendorContact/Components/_VendorContactCreateForm.razor new file mode 100644 index 0000000..26b585f --- /dev/null +++ b/Features/Thirdparty/VendorContact/Components/_VendorContactCreateForm.razor @@ -0,0 +1,101 @@ +@using Indotalent.Features.Thirdparty.VendorContact +@using Indotalent.Features.Thirdparty.VendorContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorContactService VendorContactService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Contact + Register a new contact person for your vendors. +
+
+
+ + + + + + Contact Information + + + + Contact Name + + + + Vendor + + @foreach (var item in _lookupData.Vendors) + { + @item.Name + } + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description / Notes + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Contact + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateVendorContactValidator _validator = new(); + private CreateVendorContactRequest _model = new(); + private LookupVendorContactResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await VendorContactService.GetVendorContactLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorContactService.CreateVendorContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Contact created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Components/_VendorContactDataTable.razor b/Features/Thirdparty/VendorContact/Components/_VendorContactDataTable.razor new file mode 100644 index 0000000..b15cc04 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Components/_VendorContactDataTable.razor @@ -0,0 +1,280 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorContact +@using Indotalent.Features.Thirdparty.VendorContact.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject VendorContactService VendorContactService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Vendor Contact + Manage individual contact persons for your vendors. +
+
+ + / + Third Party + / + Vendor Contact +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedContact != null) + { + View + Edit + Remove + + } + else + { + + Add New Contact + + } +
+
+ + + + + + Contact Name + + + Vendor + + Job Title + Email / Phone + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.VendorName + @context.JobTitle + +
+ @context.EmailAddress + @context.PhoneNumber +
+
+
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _contacts = new(); + private GetVendorContactListResponse? _selectedContact; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedContact = null; + StateHasChanged(); + try + { + var response = await VendorContactService.GetVendorContactListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _contacts = response.Value ?? new List(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _contacts; + return _contacts.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmailAddress?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedContact = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("VendorContacts"); + worksheet.Cell(1, 1).Value = "Contact Name"; + worksheet.Cell(1, 2).Value = "Job Title"; + worksheet.Cell(1, 3).Value = "Vendor"; + worksheet.Cell(1, 4).Value = "Email"; + worksheet.Cell(1, 5).Value = "Phone"; + worksheet.Range(1, 1, 1, 5).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.JobTitle; + worksheet.Cell(currentRow, 3).Value = item.VendorName; + worksheet.Cell(currentRow, 4).Value = item.EmailAddress; + worksheet.Cell(currentRow, 5).Value = item.PhoneNumber; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "VendorContact_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedContact = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedContact = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedContact != null) { var req = await MapToUpdateRequest(_selectedContact.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedContact != null) { var req = await MapToUpdateRequest(_selectedContact.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await VendorContactService.GetVendorContactByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateVendorContactRequest { Id = d.Id, Name = d.Name, JobTitle = d.JobTitle, PhoneNumber = d.PhoneNumber, EmailAddress = d.EmailAddress, Description = d.Description, VendorId = d.VendorId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedContact == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedContact.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await VendorContactService.DeleteVendorContactByIdAsync(_selectedContact.Id); + if (success) { _selectedContact = null; await LoadData(); Snackbar.Add("Contact deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Components/_VendorContactUpdateForm.razor b/Features/Thirdparty/VendorContact/Components/_VendorContactUpdateForm.razor new file mode 100644 index 0000000..825f369 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Components/_VendorContactUpdateForm.razor @@ -0,0 +1,143 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorContact +@using Indotalent.Features.Thirdparty.VendorContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorContactService VendorContactService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Contact Details" : "Edit Contact") + @(ReadOnly ? "Viewing contact person profile." : "Modify contact details.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Contact Information + + + + Contact Name + + + + Vendor + + @foreach (var item in _lookupData.Vendors) + { + @item.Name + } + + + + Job Title + + + + Phone Number + + + + Email Address + + + + Description / Notes + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateVendorContactRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateVendorContactValidator _validator = new(); + private UpdateVendorContactRequest _model = new(); + private LookupVendorContactResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await VendorContactService.GetVendorContactLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + _model = Data; + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorContactService.UpdateVendorContactAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Contact updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactHandler.cs new file mode 100644 index 0000000..6b067af --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class CreateVendorContactRequest +{ + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } +} + +public class CreateVendorContactResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateVendorContactCommand(CreateVendorContactRequest Data) : IRequest; + +public class CreateVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateVendorContactCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.VendorContact); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.VendorContact + { + AutoNumber = autoNo, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description, + VendorId = request.Data.VendorId + }; + + _context.VendorContact.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateVendorContactResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactValidator.cs b/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactValidator.cs new file mode 100644 index 0000000..6774ed2 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/CreateVendorContactValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class CreateVendorContactValidator : AbstractValidator +{ + public CreateVendorContactValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Contact Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.VendorId) + .NotEmpty().WithMessage("Vendor is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/DeleteVendorContactByIdHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/DeleteVendorContactByIdHandler.cs new file mode 100644 index 0000000..8cebedb --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/DeleteVendorContactByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public record DeleteVendorContactByIdRequest(string Id); + +public record DeleteVendorContactByIdCommand(DeleteVendorContactByIdRequest Data) : IRequest; + +public class DeleteVendorContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteVendorContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteVendorContactByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.VendorContact.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactByIdHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactByIdHandler.cs new file mode 100644 index 0000000..4884e7a --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class GetVendorContactByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetVendorContactByIdQuery(string Id) : IRequest; + +public class GetVendorContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetVendorContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetVendorContactByIdQuery request, CancellationToken cancellationToken) + { + return await _context.VendorContact + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetVendorContactByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + Description = x.Description, + VendorId = x.VendorId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactListHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactListHandler.cs new file mode 100644 index 0000000..5dafae8 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/GetVendorContactListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class GetVendorContactListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? VendorName { get; set; } +} + +public record GetVendorContactListQuery() : IRequest>; + +public class GetVendorContactListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetVendorContactListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetVendorContactListQuery request, CancellationToken cancellationToken) + { + return await _context.VendorContact + .AsNoTracking() + .Include(x => x.Vendor) + .OrderBy(x => x.Name) + .Select(x => new GetVendorContactListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + VendorName = x.Vendor != null ? x.Vendor.Name : string.Empty + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/LookupVendorContactHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/LookupVendorContactHandler.cs new file mode 100644 index 0000000..4e07fe9 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/LookupVendorContactHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class LookupVendorContactResponse +{ + public List Vendors { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupVendorContactQuery() : IRequest; + +public class LookupVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupVendorContactQuery request, CancellationToken cancellationToken) + { + var result = new LookupVendorContactResponse(); + + result.Vendors = await _context.Vendor + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactHandler.cs b/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactHandler.cs new file mode 100644 index 0000000..d506111 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class UpdateVendorContactRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateVendorContactResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateVendorContactCommand(UpdateVendorContactRequest Data) : IRequest; + +public class UpdateVendorContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateVendorContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateVendorContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateVendorContactResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + entity.VendorId = request.Data.VendorId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateVendorContactResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactValidator.cs b/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactValidator.cs new file mode 100644 index 0000000..4c48604 --- /dev/null +++ b/Features/Thirdparty/VendorContact/Cqrs/UpdateVendorContactValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorContact.Cqrs; + +public class UpdateVendorContactValidator : AbstractValidator +{ + public UpdateVendorContactValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Contact Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.VendorId) + .NotEmpty().WithMessage("Vendor is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/VendorContactEndpoint.cs b/Features/Thirdparty/VendorContact/VendorContactEndpoint.cs new file mode 100644 index 0000000..cfb1562 --- /dev/null +++ b/Features/Thirdparty/VendorContact/VendorContactEndpoint.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.VendorContact.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.VendorContact; + +public static class VendorContactEndpoint +{ + public static void MapVendorContactEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/vendor-contact").WithTags("VendorContacts") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetVendorContactListQuery()); + + return result.ToApiResponse("Vendor contact list retrieved successfully"); + }) + .WithName("GetVendorContactList") + .WithTags("VendorContacts"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetVendorContactByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Vendor contact detail retrieved successfully" + : $"Vendor contact with ID {id} not found"); + }) + .WithName("GetVendorContactById") + .WithTags("VendorContacts"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupVendorContactQuery()); + + return result.ToApiResponse("Vendor contact lookup data retrieved successfully"); + }) + .WithName("GetVendorContactLookup") + .WithTags("VendorContacts"); + + group.MapPost("/", async (CreateVendorContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateVendorContactCommand(request)); + + return result.ToApiResponse("Vendor contact has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateVendorContact") + .WithTags("VendorContacts"); + + group.MapPost("/update", async (UpdateVendorContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateVendorContactCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The contact data could not be found."); + } + + return result.ToApiResponse("Vendor contact has been updated successfully"); + }) + .WithName("UpdateVendorContact") + .WithTags("VendorContacts"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteVendorContactByIdCommand(new DeleteVendorContactByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The contact data could not be found."); + } + + return true.ToApiResponse("Vendor contact has been deleted successfully"); + }) + .WithName("DeleteVendorContactById") + .WithTags("VendorContacts"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorContact/VendorContactService.cs b/Features/Thirdparty/VendorContact/VendorContactService.cs new file mode 100644 index 0000000..60c90fb --- /dev/null +++ b/Features/Thirdparty/VendorContact/VendorContactService.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.VendorContact.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.VendorContact; + +public class VendorContactService : BaseService +{ + private readonly RestClient _client; + + public VendorContactService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetVendorContactListAsync() + { + var request = new RestRequest("api/vendor-contact", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetVendorContactByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-contact/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetVendorContactLookupAsync() + { + var request = new RestRequest("api/vendor-contact/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateVendorContactAsync(CreateVendorContactRequest data) + { + var request = new RestRequest("api/vendor-contact", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteVendorContactByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-contact/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateVendorContactAsync(UpdateVendorContactRequest data) + { + var request = new RestRequest("api/vendor-contact/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Components/VendorGroupPage.razor b/Features/Thirdparty/VendorGroup/Components/VendorGroupPage.razor new file mode 100644 index 0000000..ecc915e --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Components/VendorGroupPage.razor @@ -0,0 +1,28 @@ +@page "/thirdparty/vendor-group" +@using Indotalent.Features.Thirdparty.VendorGroup +@using Indotalent.Features.Thirdparty.VendorGroup.Cqrs +@using Indotalent.Features.Thirdparty.VendorGroup.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_VendorGroupCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_VendorGroupUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_VendorGroupDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateVendorGroupRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateVendorGroupRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Components/_VendorGroupCreateForm.razor b/Features/Thirdparty/VendorGroup/Components/_VendorGroupCreateForm.razor new file mode 100644 index 0000000..b09be5c --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Components/_VendorGroupCreateForm.razor @@ -0,0 +1,72 @@ +@using Indotalent.Features.Thirdparty.VendorGroup +@using Indotalent.Features.Thirdparty.VendorGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorGroupService VendorGroupService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Vendor Group + Establish new categorization for your vendor segments. +
+
+
+ + + + + + General Information + + + + Group Name + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Group + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateVendorGroupValidator _validator = new(); + private CreateVendorGroupRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorGroupService.CreateVendorGroupAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor group created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Components/_VendorGroupDataTable.razor b/Features/Thirdparty/VendorGroup/Components/_VendorGroupDataTable.razor new file mode 100644 index 0000000..dd56f2b --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Components/_VendorGroupDataTable.razor @@ -0,0 +1,281 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorGroup +@using Indotalent.Features.Thirdparty.VendorGroup.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject VendorGroupService VendorGroupService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Vendor Group + Manage and categorize your vendor segments. +
+
+ + / + Third Party + / + Vendor Group +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedGroup != null) + { + View + Edit + Remove + + } + else + { + + Add New Group + + } +
+
+ + + + + + Group Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + +
+ @context.Name +
+
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _groups = new(); + private GetVendorGroupListResponse? _selectedGroup; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedGroup = null; + StateHasChanged(); + try + { + var response = await VendorGroupService.GetVendorGroupListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _groups = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _groups; + return _groups.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedGroup = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("VendorGroups"); + worksheet.Cell(1, 1).Value = "Group Name"; + worksheet.Cell(1, 2).Value = "Auto Number"; + worksheet.Cell(1, 3).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 3); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Description; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "VendorGroup_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedGroup = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedGroup = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedGroup != null) { var req = await MapToUpdateRequest(_selectedGroup.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedGroup != null) { var req = await MapToUpdateRequest(_selectedGroup.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await VendorGroupService.GetVendorGroupByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateVendorGroupRequest { Id = d.Id, Name = d.Name, Description = d.Description, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedGroup == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedGroup.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await VendorGroupService.DeleteVendorGroupByIdAsync(_selectedGroup.Id); + if (isSuccess) { _selectedGroup = null; await LoadData(); Snackbar.Add("Vendor group deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Components/_VendorGroupUpdateForm.razor b/Features/Thirdparty/VendorGroup/Components/_VendorGroupUpdateForm.razor new file mode 100644 index 0000000..ea78ca7 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Components/_VendorGroupUpdateForm.razor @@ -0,0 +1,87 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Thirdparty.VendorGroup +@using Indotalent.Features.Thirdparty.VendorGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject VendorGroupService VendorGroupService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Vendor Group Details" : "Edit Vendor Group") + @(ReadOnly ? "Viewing vendor group classification." : "Modify existing group information.") +
+
+
+ + + + + + General Information + + + + Group Name + + + + Description + + + + Audit History + Created At@DateTimeExtensions.ToString(_model.CreatedAt) + Created By@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + Last Updated At@DateTimeExtensions.ToString(_model.UpdatedAt) + Last Updated By@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + @if (_processing) { + + Updating... + } else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateVendorGroupRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateVendorGroupValidator _validator = new(); + private UpdateVendorGroupRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = Data; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await VendorGroupService.UpdateVendorGroupAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Vendor group updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupHandler.cs b/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupHandler.cs new file mode 100644 index 0000000..b2184d4 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class CreateVendorGroupRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateVendorGroupResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateVendorGroupCommand(CreateVendorGroupRequest Data) : IRequest; + +public class CreateVendorGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateVendorGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateVendorGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.VendorGroup + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor Group", request.Data.Name ?? string.Empty); + } + + + var entity = new Data.Entities.VendorGroup + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.VendorGroup.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateVendorGroupResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupValidator.cs b/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupValidator.cs new file mode 100644 index 0000000..7b48f28 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/CreateVendorGroupValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class CreateVendorGroupValidator : AbstractValidator +{ + public CreateVendorGroupValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/DeleteVendorGroupByIdHandler.cs b/Features/Thirdparty/VendorGroup/Cqrs/DeleteVendorGroupByIdHandler.cs new file mode 100644 index 0000000..9b5062b --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/DeleteVendorGroupByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public record DeleteVendorGroupByIdRequest(string Id); + +public record DeleteVendorGroupByIdCommand(DeleteVendorGroupByIdRequest Data) : IRequest; + +public class DeleteVendorGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteVendorGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteVendorGroupByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.VendorGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.VendorGroup.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupByIdHandler.cs b/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupByIdHandler.cs new file mode 100644 index 0000000..e9d491d --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class GetVendorGroupByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetVendorGroupByIdQuery(string Id) : IRequest; + +public class GetVendorGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetVendorGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetVendorGroupByIdQuery request, CancellationToken cancellationToken) + { + return await _context.VendorGroup + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetVendorGroupByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupListHandler.cs b/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupListHandler.cs new file mode 100644 index 0000000..703cd26 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/GetVendorGroupListHandler.cs @@ -0,0 +1,35 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class GetVendorGroupListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + +public record GetVendorGroupListQuery() : IRequest>; + +public class GetVendorGroupListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetVendorGroupListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetVendorGroupListQuery request, CancellationToken cancellationToken) + { + return await _context.VendorGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetVendorGroupListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupHandler.cs b/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupHandler.cs new file mode 100644 index 0000000..7dc0e5b --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class UpdateVendorGroupRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateVendorGroupResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateVendorGroupCommand(UpdateVendorGroupRequest Data) : IRequest; + +public class UpdateVendorGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateVendorGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateVendorGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.VendorGroup + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Vendor Group", request.Data.Name ?? string.Empty); + } + + var entity = await _context.VendorGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateVendorGroupResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateVendorGroupResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupValidator.cs b/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupValidator.cs new file mode 100644 index 0000000..027abeb --- /dev/null +++ b/Features/Thirdparty/VendorGroup/Cqrs/UpdateVendorGroupValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Thirdparty.VendorGroup.Cqrs; + +public class UpdateVendorGroupValidator : AbstractValidator +{ + public UpdateVendorGroupValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/VendorGroupEndpoint.cs b/Features/Thirdparty/VendorGroup/VendorGroupEndpoint.cs new file mode 100644 index 0000000..d8c07c7 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/VendorGroupEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Thirdparty.VendorGroup.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Thirdparty.VendorGroup; + +public static class VendorGroupEndpoint +{ + public static void MapVendorGroupEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/vendor-group").WithTags("VendorGroups") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetVendorGroupListQuery()); + + return result.ToApiResponse("Vendor group list retrieved successfully"); + }) + .WithName("GetVendorGroupList") + .WithTags("VendorGroups"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetVendorGroupByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Vendor group detail retrieved successfully" + : $"Vendor group with ID {id} not found"); + }) + .WithName("GetVendorGroupById") + .WithTags("VendorGroups"); + + group.MapPost("/", async (CreateVendorGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateVendorGroupCommand(request)); + + return result.ToApiResponse("Vendor group has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateVendorGroup") + .WithTags("VendorGroups"); + + group.MapPost("/update", async (UpdateVendorGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateVendorGroupCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The vendor group data could not be found."); + } + + return result.ToApiResponse("Vendor group has been updated successfully"); + }) + .WithName("UpdateVendorGroup") + .WithTags("VendorGroups"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteVendorGroupByIdCommand(new DeleteVendorGroupByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The vendor group data could not be found."); + } + + return true.ToApiResponse("Vendor group has been deleted successfully"); + }) + .WithName("DeleteVendorGroupById") + .WithTags("VendorGroups"); + } +} \ No newline at end of file diff --git a/Features/Thirdparty/VendorGroup/VendorGroupService.cs b/Features/Thirdparty/VendorGroup/VendorGroupService.cs new file mode 100644 index 0000000..7c9f906 --- /dev/null +++ b/Features/Thirdparty/VendorGroup/VendorGroupService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Thirdparty.VendorGroup.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Thirdparty.VendorGroup; + +public class VendorGroupService : BaseService +{ + private readonly RestClient _client; + + public VendorGroupService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetVendorGroupListAsync() + { + var request = new RestRequest("api/vendor-group", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetVendorGroupByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-group/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateVendorGroupAsync(CreateVendorGroupRequest data) + { + var request = new RestRequest("api/vendor-group", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteVendorGroupByIdAsync(string id) + { + var request = new RestRequest($"api/vendor-group/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateVendorGroupAsync(UpdateVendorGroupRequest data) + { + var request = new RestRequest("api/vendor-group/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/BookingGroupEndpoint.cs b/Features/Utilities/BookingGroup/BookingGroupEndpoint.cs new file mode 100644 index 0000000..da307ed --- /dev/null +++ b/Features/Utilities/BookingGroup/BookingGroupEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.BookingGroup.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.BookingGroup; + +public static class BookingGroupEndpoint +{ + public static void MapBookingGroupEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/booking-group").WithTags("BookingGroups") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBookingGroupListQuery()); + + return result.ToApiResponse("Booking group list retrieved successfully"); + }) + .WithName("GetBookingGroupList") + .WithTags("BookingGroups"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBookingGroupByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Booking group detail retrieved successfully" + : $"Booking group with ID {id} not found"); + }) + .WithName("GetBookingGroupById") + .WithTags("BookingGroups"); + + group.MapPost("/", async (CreateBookingGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBookingGroupCommand(request)); + + return result.ToApiResponse("Booking group has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBookingGroup") + .WithTags("BookingGroups"); + + group.MapPost("/update", async (UpdateBookingGroupRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBookingGroupCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The booking group data could not be found."); + } + + return result.ToApiResponse("Booking group has been updated successfully"); + }) + .WithName("UpdateBookingGroup") + .WithTags("BookingGroups"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBookingGroupByIdCommand(new DeleteBookingGroupByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The booking group data could not be found."); + } + + return true.ToApiResponse("Booking group has been deleted successfully"); + }) + .WithName("DeleteBookingGroupById") + .WithTags("BookingGroups"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/BookingGroupService.cs b/Features/Utilities/BookingGroup/BookingGroupService.cs new file mode 100644 index 0000000..6a9cbfe --- /dev/null +++ b/Features/Utilities/BookingGroup/BookingGroupService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.BookingGroup.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.BookingGroup; + +public class BookingGroupService : BaseService +{ + private readonly RestClient _client; + + public BookingGroupService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBookingGroupListAsync() + { + var request = new RestRequest("api/booking-group", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBookingGroupByIdAsync(string id) + { + var request = new RestRequest($"api/booking-group/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBookingGroupAsync(CreateBookingGroupRequest data) + { + var request = new RestRequest("api/booking-group", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBookingGroupByIdAsync(string id) + { + var request = new RestRequest($"api/booking-group/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateBookingGroupAsync(UpdateBookingGroupRequest data) + { + var request = new RestRequest("api/booking-group/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Components/BookingGroupPage.razor b/Features/Utilities/BookingGroup/Components/BookingGroupPage.razor new file mode 100644 index 0000000..a92acbd --- /dev/null +++ b/Features/Utilities/BookingGroup/Components/BookingGroupPage.razor @@ -0,0 +1,53 @@ +@page "/utilities/booking-group" +@using Indotalent.Features.Utilities.BookingGroup +@using Indotalent.Features.Utilities.BookingGroup.Cqrs +@using Indotalent.Features.Utilities.BookingGroup.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BookingGroupCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BookingGroupUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_BookingGroupDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateBookingGroupRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBookingGroupRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Components/_BookingGroupCreateForm.razor b/Features/Utilities/BookingGroup/Components/_BookingGroupCreateForm.razor new file mode 100644 index 0000000..5eebdad --- /dev/null +++ b/Features/Utilities/BookingGroup/Components/_BookingGroupCreateForm.razor @@ -0,0 +1,97 @@ +@using Indotalent.Features.Utilities.BookingGroup +@using Indotalent.Features.Utilities.BookingGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingGroupService BookingGroupService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Booking Group + Enter details for the new booking group classification. +
+
+
+ + + + + + Group Name + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Booking Group + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBookingGroupValidator _validator = new(); + private CreateBookingGroupRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingGroupService.CreateBookingGroupAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking group created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Components/_BookingGroupDataTable.razor b/Features/Utilities/BookingGroup/Components/_BookingGroupDataTable.razor new file mode 100644 index 0000000..e8d72dc --- /dev/null +++ b/Features/Utilities/BookingGroup/Components/_BookingGroupDataTable.razor @@ -0,0 +1,373 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingGroup +@using Indotalent.Features.Utilities.BookingGroup.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BookingGroupService BookingGroupService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Booking Group Management + Manage booking group categories and classifications. +
+
+ + / + Utilities + / + Booking Group +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedBookingGroup != null) + { + View + Edit + Remove + + } + else + { + + Add New Booking Group + + } +
+
+ + + + + + Group Name + + Description + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _bookingGroups = new(); + private GetBookingGroupListResponse? _selectedBookingGroup; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedBookingGroup = null; + StateHasChanged(); + try + { + var response = await BookingGroupService.GetBookingGroupListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _bookingGroups = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _bookingGroups; + return _bookingGroups.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedBookingGroup = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("BookingGroups"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Group Name"; + worksheet.Cell(currentRow, 2).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "BookingGroups_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedBookingGroup = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedBookingGroup = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedBookingGroup == null) return; + var request = await MapToUpdateRequest(_selectedBookingGroup.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedBookingGroup == null) return; + var request = await MapToUpdateRequest(_selectedBookingGroup.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await BookingGroupService.GetBookingGroupByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateBookingGroupRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedBookingGroup == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedBookingGroup.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await BookingGroupService.DeleteBookingGroupByIdAsync(_selectedBookingGroup.Id!); + if (isSuccess) + { + _selectedBookingGroup = null; + await LoadData(); + Snackbar.Add("Booking group deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Components/_BookingGroupUpdateForm.razor b/Features/Utilities/BookingGroup/Components/_BookingGroupUpdateForm.razor new file mode 100644 index 0000000..270341a --- /dev/null +++ b/Features/Utilities/BookingGroup/Components/_BookingGroupUpdateForm.razor @@ -0,0 +1,143 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingGroup +@using Indotalent.Features.Utilities.BookingGroup.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingGroupService BookingGroupService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Booking Group Details" : "Edit Booking Group") + @(ReadOnly ? "Viewing booking group specification." : "Modify existing booking group information.") +
+
+
+ + + + + + Group Name + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateBookingGroupRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateBookingGroupValidator _validator = new(); + private UpdateBookingGroupRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateBookingGroupRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingGroupService.UpdateBookingGroupAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking group updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupHandler.cs b/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupHandler.cs new file mode 100644 index 0000000..5712f6e --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class CreateBookingGroupRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateBookingGroupResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateBookingGroupCommand(CreateBookingGroupRequest Data) : IRequest; + +public class CreateBookingGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateBookingGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBookingGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.BookingGroup + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Booking Group", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.BookingGroup + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.BookingGroup.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBookingGroupResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupValidator.cs b/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupValidator.cs new file mode 100644 index 0000000..4ef4e67 --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/CreateBookingGroupValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class CreateBookingGroupValidator : AbstractValidator +{ + public CreateBookingGroupValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/DeleteBookingGroupByIdHandler.cs b/Features/Utilities/BookingGroup/Cqrs/DeleteBookingGroupByIdHandler.cs new file mode 100644 index 0000000..5cfb258 --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/DeleteBookingGroupByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class DeleteBookingGroupByIdRequest +{ + public DeleteBookingGroupByIdRequest(string id) => Id = id; + public string Id { get; set; } +} + +public record DeleteBookingGroupByIdCommand(DeleteBookingGroupByIdRequest Data) : IRequest; + +public class DeleteBookingGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteBookingGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBookingGroupByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.BookingGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.BookingGroup.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupByIdHandler.cs b/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupByIdHandler.cs new file mode 100644 index 0000000..daf114d --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class GetBookingGroupByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingGroupByIdQuery(string Id) : IRequest; + +public class GetBookingGroupByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBookingGroupByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBookingGroupByIdQuery request, CancellationToken cancellationToken) + { + return await _context.BookingGroup + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetBookingGroupByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupListHandler.cs b/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupListHandler.cs new file mode 100644 index 0000000..7e9f452 --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/GetBookingGroupListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class GetBookingGroupListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingGroupListQuery() : IRequest>; + +public class GetBookingGroupListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBookingGroupListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBookingGroupListQuery request, CancellationToken cancellationToken) + { + return await _context.BookingGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetBookingGroupListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupHandler.cs b/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupHandler.cs new file mode 100644 index 0000000..f1370b1 --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class UpdateBookingGroupRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateBookingGroupResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBookingGroupCommand(UpdateBookingGroupRequest Data) : IRequest; + +public class UpdateBookingGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateBookingGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBookingGroupCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.BookingGroup + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Booking Group", request.Data.Name ?? string.Empty); + } + + var entity = await _context.BookingGroup + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateBookingGroupResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBookingGroupResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupValidator.cs b/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupValidator.cs new file mode 100644 index 0000000..f2c77fc --- /dev/null +++ b/Features/Utilities/BookingGroup/Cqrs/UpdateBookingGroupValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingGroup.Cqrs; + +public class UpdateBookingGroupValidator : AbstractValidator +{ + public UpdateBookingGroupValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/BookingManagerEndpoint.cs b/Features/Utilities/BookingManager/BookingManagerEndpoint.cs new file mode 100644 index 0000000..4525661 --- /dev/null +++ b/Features/Utilities/BookingManager/BookingManagerEndpoint.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.BookingManager.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.BookingManager; + +public static class BookingManagerEndpoint +{ + public static void MapBookingManagerEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/booking-manager").WithTags("BookingManager") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBookingListQuery()); + return result.ToApiResponse("Booking list retrieved successfully"); + }) + .WithName("GetBookingList") + .WithTags("BookingManager"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBookingByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Booking detail retrieved successfully" + : $"Booking with ID {id} not found"); + }) + .WithName("GetBookingById") + .WithTags("BookingManager"); + + group.MapGet("/lookup-data", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupBookingDataQuery()); + return result.ToApiResponse("Booking lookup data retrieved successfully"); + }) + .WithName("GetBookingLookupData") + .WithTags("BookingManager"); + + group.MapPost("/", async (CreateBookingRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBookingCommand(request)); + return result.ToApiResponse("Booking has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBooking") + .WithTags("BookingManager"); + + group.MapPost("/update", async (UpdateBookingRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBookingCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The booking data could not be found."); + } + return result.ToApiResponse("Booking has been updated successfully"); + }) + .WithName("UpdateBooking") + .WithTags("BookingManager"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBookingByIdCommand(new DeleteBookingByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The booking data could not be found."); + } + return true.ToApiResponse("Booking has been deleted successfully"); + }) + .WithName("DeleteBookingById") + .WithTags("BookingManager"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/BookingManagerService.cs b/Features/Utilities/BookingManager/BookingManagerService.cs new file mode 100644 index 0000000..3e0e65d --- /dev/null +++ b/Features/Utilities/BookingManager/BookingManagerService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.BookingManager.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.BookingManager; + +public class BookingManagerService : BaseService +{ + private readonly RestClient _client; + + public BookingManagerService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBookingListAsync() + { + var request = new RestRequest("api/booking-manager", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBookingByIdAsync(string id) + { + var request = new RestRequest($"api/booking-manager/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetLookupDataAsync() + { + var request = new RestRequest("api/booking-manager/lookup-data", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBookingAsync(CreateBookingRequest data) + { + var request = new RestRequest("api/booking-manager", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBookingByIdAsync(string id) + { + var request = new RestRequest($"api/booking-manager/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateBookingAsync(UpdateBookingRequest data) + { + var request = new RestRequest("api/booking-manager/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Components/BookingManagerPage.razor b/Features/Utilities/BookingManager/Components/BookingManagerPage.razor new file mode 100644 index 0000000..3922d99 --- /dev/null +++ b/Features/Utilities/BookingManager/Components/BookingManagerPage.razor @@ -0,0 +1,47 @@ +@page "/utilities/booking" +@using Indotalent.Features.Utilities.BookingManager +@using Indotalent.Features.Utilities.BookingManager.Cqrs +@using Indotalent.Features.Utilities.BookingManager.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BookingCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BookingUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_BookingDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateBookingRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBookingRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Components/_BookingCreateForm.razor b/Features/Utilities/BookingManager/Components/_BookingCreateForm.razor new file mode 100644 index 0000000..78c7dae --- /dev/null +++ b/Features/Utilities/BookingManager/Components/_BookingCreateForm.razor @@ -0,0 +1,172 @@ +@using Indotalent.Features.Utilities.BookingManager +@using Indotalent.Features.Utilities.BookingManager.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingManagerService BookingManagerService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Booking + Schedule a new resource booking. +
+
+
+ + + + + + Subject + + + + + Start Date + + + + + Start Time + + + + + End Date + + + + + End Time + + + + + Booking Resource + + @foreach (var item in _lookupData.Resources) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookupData.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Booking + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBookingValidator _validator = new(); + private CreateBookingRequest _model = new() { Status = Indotalent.Data.Enums.BookingStatus.Draft }; + private LookupBookingDataResponse _lookupData = new(); + private bool _processing = false; + + private DateTime? _startDate = DateTime.Today; + private TimeSpan? _startTime = DateTime.Now.TimeOfDay; + private DateTime? _endDate = DateTime.Today; + private TimeSpan? _endTime = DateTime.Now.TimeOfDay.Add(TimeSpan.FromHours(1)); + + protected override async Task OnInitializedAsync() + { + var response = await BookingManagerService.GetLookupDataAsync(); + if (response != null && response.IsSuccess) + { + _lookupData = response.Value!; + } + } + + private async Task Submit() + { + if (_startDate.HasValue && _startTime.HasValue) + { + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + } + + if (_endDate.HasValue && _endTime.HasValue) + { + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + } + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingManagerService.CreateBookingAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Components/_BookingDataTable.razor b/Features/Utilities/BookingManager/Components/_BookingDataTable.razor new file mode 100644 index 0000000..5e55b8d --- /dev/null +++ b/Features/Utilities/BookingManager/Components/_BookingDataTable.razor @@ -0,0 +1,388 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingManager +@using Indotalent.Features.Utilities.BookingManager.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BookingManagerService BookingManagerService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Booking Management + Manage schedules, resources, and booking status. +
+
+ + / + Utilities + / + Booking +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedBooking != null) + { + View + Edit + Remove + + } + else + { + + Add New Booking + + } +
+
+ + + + + + No + + + Subject + + Start Time + Resource + Status + + + + + + @context.AutoNumber + + @context.Subject + + @DateTimeExtensions.ToString(context.StartTime) + @context.ResourceName + + @context.Status + + + + +
+
+ Rows per page: + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _bookings = new(); + private GetBookingListResponse? _selectedBooking; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedBooking = null; + StateHasChanged(); + try + { + var response = await BookingManagerService.GetBookingListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _bookings = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _bookings; + return _bookings.Where(x => + (x.Subject?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ResourceName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedBooking = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Bookings"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Auto Number"; + worksheet.Cell(currentRow, 2).Value = "Subject"; + worksheet.Cell(currentRow, 3).Value = "Start Time"; + worksheet.Cell(currentRow, 4).Value = "Resource"; + worksheet.Cell(currentRow, 5).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.Subject; + worksheet.Cell(currentRow, 3).Value = item.StartTime?.ToString("yyyy-MM-dd HH:mm"); + worksheet.Cell(currentRow, 4).Value = item.ResourceName; + worksheet.Cell(currentRow, 5).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Booking_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedBooking = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedBooking = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedBooking == null) return; + var request = await MapToUpdateRequest(_selectedBooking.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedBooking == null) return; + var request = await MapToUpdateRequest(_selectedBooking.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await BookingManagerService.GetBookingByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateBookingRequest + { + Id = detail.Id, + Subject = detail.Subject, + StartTime = detail.StartTime, + EndTime = detail.EndTime, + StartTimezone = detail.StartTimezone, + EndTimezone = detail.EndTimezone, + Location = detail.Location, + Description = detail.Description, + IsAllDay = detail.IsAllDay, + IsReadOnly = detail.IsReadOnly, + IsBlock = detail.IsBlock, + RecurrenceRule = detail.RecurrenceRule, + Status = detail.Status, + BookingResourceId = detail.BookingResourceId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedBooking == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedBooking.Subject } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await BookingManagerService.DeleteBookingByIdAsync(_selectedBooking.Id!); + if (isSuccess) + { + _selectedBooking = null; + await LoadData(); + Snackbar.Add("Booking deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Components/_BookingUpdateForm.razor b/Features/Utilities/BookingManager/Components/_BookingUpdateForm.razor new file mode 100644 index 0000000..f7af7d7 --- /dev/null +++ b/Features/Utilities/BookingManager/Components/_BookingUpdateForm.razor @@ -0,0 +1,242 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingManager +@using Indotalent.Features.Utilities.BookingManager.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingManagerService BookingManagerService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Booking Details" : "Edit Booking") + @(ReadOnly ? "Viewing booking specification." : "Modify existing booking information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Subject + + + + + Start Date + + + + + Start Time + + + + + End Date + + + + + End Time + + + + + Booking Resource + + @foreach (var item in _lookupData.Resources) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookupData.Statuses) + { + @item.Name + } + + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateBookingRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateBookingValidator _validator = new(); + private UpdateBookingRequest _model = new(); + private LookupBookingDataResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + private DateTime? _startDate; + private TimeSpan? _startTime; + private DateTime? _endDate; + private TimeSpan? _endTime; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await BookingManagerService.GetLookupDataAsync(); + if (response != null && response.IsSuccess) + { + _lookupData = response.Value!; + } + + _model = new UpdateBookingRequest + { + Id = Data.Id, + Subject = Data.Subject, + StartTime = Data.StartTime, + EndTime = Data.EndTime, + StartTimezone = Data.StartTimezone, + EndTimezone = Data.EndTimezone, + Location = Data.Location, + Description = Data.Description, + IsAllDay = Data.IsAllDay, + IsReadOnly = Data.IsReadOnly, + IsBlock = Data.IsBlock, + RecurrenceRule = Data.RecurrenceRule, + Status = Data.Status, + BookingResourceId = Data.BookingResourceId, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + if (_model.StartTime.HasValue) + { + _startDate = _model.StartTime.Value.Date; + _startTime = _model.StartTime.Value.TimeOfDay; + } + + if (_model.EndTime.HasValue) + { + _endDate = _model.EndTime.Value.Date; + _endTime = _model.EndTime.Value.TimeOfDay; + } + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + + if (_startDate.HasValue && _startTime.HasValue) + { + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + } + + if (_endDate.HasValue && _endTime.HasValue) + { + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + } + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingManagerService.UpdateBookingAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/CreateBookingHandler.cs b/Features/Utilities/BookingManager/Cqrs/CreateBookingHandler.cs new file mode 100644 index 0000000..7b50547 --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/CreateBookingHandler.cs @@ -0,0 +1,76 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class CreateBookingRequest +{ + public string? Subject { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? StartTimezone { get; set; } + public string? EndTimezone { get; set; } + public string? Location { get; set; } + public string? Description { get; set; } + public bool? IsAllDay { get; set; } + public bool? IsReadOnly { get; set; } + public bool? IsBlock { get; set; } + public string? RecurrenceRule { get; set; } + public BookingStatus Status { get; set; } + public string? BookingResourceId { get; set; } +} + +public class CreateBookingResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateBookingCommand(CreateBookingRequest Data) : IRequest; + +public class CreateBookingHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateBookingHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBookingCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Booking); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Booking + { + AutoNumber = autoNo, + Subject = request.Data.Subject, + StartTime = request.Data.StartTime, + EndTime = request.Data.EndTime, + StartTimezone = request.Data.StartTimezone, + EndTimezone = request.Data.EndTimezone, + Location = request.Data.Location, + Description = request.Data.Description, + IsAllDay = request.Data.IsAllDay, + IsReadOnly = request.Data.IsReadOnly, + IsBlock = request.Data.IsBlock, + RecurrenceRule = request.Data.RecurrenceRule, + Status = request.Data.Status, + BookingResourceId = request.Data.BookingResourceId + }; + + _context.Booking.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBookingResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/CreateBookingValidator.cs b/Features/Utilities/BookingManager/Cqrs/CreateBookingValidator.cs new file mode 100644 index 0000000..942549a --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/CreateBookingValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class CreateBookingValidator : AbstractValidator +{ + public CreateBookingValidator() + { + RuleFor(x => x.Subject) + .NotEmpty().WithMessage("Subject is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.StartTime) + .NotEmpty().WithMessage("Start Time is required"); + + RuleFor(x => x.EndTime) + .NotEmpty().WithMessage("End Time is required"); + + RuleFor(x => x.BookingResourceId) + .NotEmpty().WithMessage("Resource is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/DeleteBookingByIdHandler.cs b/Features/Utilities/BookingManager/Cqrs/DeleteBookingByIdHandler.cs new file mode 100644 index 0000000..a7eb649 --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/DeleteBookingByIdHandler.cs @@ -0,0 +1,31 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class DeleteBookingByIdRequest +{ + public DeleteBookingByIdRequest(string id) => Id = id; + public string Id { get; set; } +} + +public record DeleteBookingByIdCommand(DeleteBookingByIdRequest Data) : IRequest; + +public class DeleteBookingByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteBookingByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBookingByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Booking + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Booking.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/GetBookingByIdHandler.cs b/Features/Utilities/BookingManager/Cqrs/GetBookingByIdHandler.cs new file mode 100644 index 0000000..4f18a0a --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/GetBookingByIdHandler.cs @@ -0,0 +1,74 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class GetBookingByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Subject { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? StartTimezone { get; set; } + public string? EndTimezone { get; set; } + public string? Location { get; set; } + public string? Description { get; set; } + public bool? IsAllDay { get; set; } + public bool? IsReadOnly { get; set; } + public bool? IsBlock { get; set; } + public string? RecurrenceRule { get; set; } + public string? RecurrenceID { get; set; } + public string? FollowingID { get; set; } + public string? RecurrenceException { get; set; } + public BookingStatus Status { get; set; } + public string? BookingResourceId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingByIdQuery(string Id) : IRequest; + +public class GetBookingByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBookingByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBookingByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Booking + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetBookingByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Subject = x.Subject, + StartTime = x.StartTime, + EndTime = x.EndTime, + StartTimezone = x.StartTimezone, + EndTimezone = x.EndTimezone, + Location = x.Location, + Description = x.Description, + IsAllDay = x.IsAllDay, + IsReadOnly = x.IsReadOnly, + IsBlock = x.IsBlock, + RecurrenceRule = x.RecurrenceRule, + RecurrenceID = x.RecurrenceID, + FollowingID = x.FollowingID, + RecurrenceException = x.RecurrenceException, + Status = x.Status, + BookingResourceId = x.BookingResourceId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/GetBookingListHandler.cs b/Features/Utilities/BookingManager/Cqrs/GetBookingListHandler.cs new file mode 100644 index 0000000..aec90d5 --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/GetBookingListHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class GetBookingListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Subject { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? Status { get; set; } + public string? ResourceName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingListQuery() : IRequest>; + +public class GetBookingListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBookingListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBookingListQuery request, CancellationToken cancellationToken) + { + return await _context.Booking + .AsNoTracking() + .Include(x => x.BookingResource) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetBookingListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Subject = x.Subject, + StartTime = x.StartTime, + EndTime = x.EndTime, + Status = x.Status.GetDescription(), + ResourceName = x.BookingResource != null ? x.BookingResource.Name : string.Empty, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/LookupBookingDataHandler.cs b/Features/Utilities/BookingManager/Cqrs/LookupBookingDataHandler.cs new file mode 100644 index 0000000..387f99a --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/LookupBookingDataHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class LookupBookingDataResponse +{ + public List Resources { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupResourceItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class LookupStatusItem +{ + public int Value { get; set; } + public string? Name { get; set; } +} + +public record LookupBookingDataQuery() : IRequest; + +public class LookupBookingDataHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupBookingDataHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupBookingDataQuery request, CancellationToken cancellationToken) + { + var resources = await _context.BookingResource + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupResourceItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + var statuses = Enum.GetValues(typeof(BookingStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }) + .ToList(); + + return new LookupBookingDataResponse + { + Resources = resources, + Statuses = statuses + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/UpdateBookingHandler.cs b/Features/Utilities/BookingManager/Cqrs/UpdateBookingHandler.cs new file mode 100644 index 0000000..d2f037a --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/UpdateBookingHandler.cs @@ -0,0 +1,76 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class UpdateBookingRequest +{ + public string? Id { get; set; } + public string? Subject { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? StartTimezone { get; set; } + public string? EndTimezone { get; set; } + public string? Location { get; set; } + public string? Description { get; set; } + public bool? IsAllDay { get; set; } + public bool? IsReadOnly { get; set; } + public bool? IsBlock { get; set; } + public string? RecurrenceRule { get; set; } + public BookingStatus Status { get; set; } + public string? BookingResourceId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateBookingResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBookingCommand(UpdateBookingRequest Data) : IRequest; + +public class UpdateBookingHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateBookingHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBookingCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Booking + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateBookingResponse { Id = request.Data.Id, Success = false }; + } + + entity.Subject = request.Data.Subject; + entity.StartTime = request.Data.StartTime; + entity.EndTime = request.Data.EndTime; + entity.StartTimezone = request.Data.StartTimezone; + entity.EndTimezone = request.Data.EndTimezone; + entity.Location = request.Data.Location; + entity.Description = request.Data.Description; + entity.IsAllDay = request.Data.IsAllDay; + entity.IsReadOnly = request.Data.IsReadOnly; + entity.IsBlock = request.Data.IsBlock; + entity.RecurrenceRule = request.Data.RecurrenceRule; + entity.Status = request.Data.Status; + entity.BookingResourceId = request.Data.BookingResourceId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBookingResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingManager/Cqrs/UpdateBookingValidator.cs b/Features/Utilities/BookingManager/Cqrs/UpdateBookingValidator.cs new file mode 100644 index 0000000..1dd52ab --- /dev/null +++ b/Features/Utilities/BookingManager/Cqrs/UpdateBookingValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingManager.Cqrs; + +public class UpdateBookingValidator : AbstractValidator +{ + public UpdateBookingValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.Subject) + .NotEmpty().WithMessage("Subject is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.StartTime) + .NotEmpty().WithMessage("Start Time is required"); + + RuleFor(x => x.EndTime) + .NotEmpty().WithMessage("End Time is required"); + + RuleFor(x => x.BookingResourceId) + .NotEmpty().WithMessage("Resource is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/BookingResourceService.cs b/Features/Utilities/BookingResource/BookingResourceService.cs new file mode 100644 index 0000000..178bd86 --- /dev/null +++ b/Features/Utilities/BookingResource/BookingResourceService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.BookingResource.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.BookingResource; + +public class BookingResourceService : BaseService +{ + private readonly RestClient _client; + + public BookingResourceService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBookingResourceListAsync() + { + var request = new RestRequest("api/booking-resource", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBookingResourceByIdAsync(string id) + { + var request = new RestRequest($"api/booking-resource/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetBookingGroupLookupAsync() + { + var request = new RestRequest("api/booking-resource/lookup-booking-group", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBookingResourceAsync(CreateBookingResourceRequest data) + { + var request = new RestRequest("api/booking-resource", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBookingResourceByIdAsync(string id) + { + var request = new RestRequest($"api/booking-resource/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateBookingResourceAsync(UpdateBookingResourceRequest data) + { + var request = new RestRequest("api/booking-resource/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/BookingResoureEndpoint.cs b/Features/Utilities/BookingResource/BookingResoureEndpoint.cs new file mode 100644 index 0000000..ed5887c --- /dev/null +++ b/Features/Utilities/BookingResource/BookingResoureEndpoint.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.BookingResource.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.BookingResource; + +public static class BookingResourceEndpoint +{ + public static void MapBookingResourceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/booking-resource").WithTags("BookingResources") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBookingResourceListQuery()); + return result.ToApiResponse("Booking resource list retrieved successfully"); + }) + .WithName("GetBookingResourceList") + .WithTags("BookingResources"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBookingResourceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Booking resource detail retrieved successfully" + : $"Booking resource with ID {id} not found"); + }) + .WithName("GetBookingResourceById") + .WithTags("BookingResources"); + + group.MapGet("/lookup-booking-group", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupBookingGroupQuery()); + return result.ToApiResponse("Booking group lookup retrieved successfully"); + }) + .WithName("GetBookingGroupLookupForResource") + .WithTags("BookingResources"); + + group.MapPost("/", async (CreateBookingResourceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBookingResourceCommand(request)); + return result.ToApiResponse("Booking resource has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBookingResource") + .WithTags("BookingResources"); + + group.MapPost("/update", async (UpdateBookingResourceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBookingResourceCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The booking resource data could not be found."); + } + return result.ToApiResponse("Booking resource has been updated successfully"); + }) + .WithName("UpdateBookingResource") + .WithTags("BookingResources"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBookingResourceByIdCommand(new DeleteBookingResourceByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The booking resource data could not be found."); + } + return true.ToApiResponse("Booking resource has been deleted successfully"); + }) + .WithName("DeleteBookingResourceById") + .WithTags("BookingResources"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Components/BookingResourcePage.razor b/Features/Utilities/BookingResource/Components/BookingResourcePage.razor new file mode 100644 index 0000000..54d9ee8 --- /dev/null +++ b/Features/Utilities/BookingResource/Components/BookingResourcePage.razor @@ -0,0 +1,47 @@ +@page "/utilities/booking-resource" +@using Indotalent.Features.Utilities.BookingResource +@using Indotalent.Features.Utilities.BookingResource.Cqrs +@using Indotalent.Features.Utilities.BookingResource.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BookingResourceCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BookingResourceUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_BookingResourceDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateBookingResourceRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBookingResourceRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Components/_BookingResourceCreateForm.razor b/Features/Utilities/BookingResource/Components/_BookingResourceCreateForm.razor new file mode 100644 index 0000000..cb4b45f --- /dev/null +++ b/Features/Utilities/BookingResource/Components/_BookingResourceCreateForm.razor @@ -0,0 +1,108 @@ +@using Indotalent.Features.Utilities.BookingResource +@using Indotalent.Features.Utilities.BookingResource.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingResourceService BookingResourceService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Booking Resource + Define a new bookable asset and its category. +
+
+
+ + + + + + Resource Name + + + + + Booking Group + + @foreach (var item in _lookupData.BookingGroups) + { + @item.Name + } + + + + + Description + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Resource + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBookingResourceValidator _validator = new(); + private CreateBookingResourceRequest _model = new(); + private LookupBookingGroupResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await BookingResourceService.GetBookingGroupLookupAsync(); + if (response != null && response.IsSuccess) + { + _lookupData = response.Value!; + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingResourceService.CreateBookingResourceAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking resource created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Components/_BookingResourceDataTable.razor b/Features/Utilities/BookingResource/Components/_BookingResourceDataTable.razor new file mode 100644 index 0000000..1a644da --- /dev/null +++ b/Features/Utilities/BookingResource/Components/_BookingResourceDataTable.razor @@ -0,0 +1,367 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingResource +@using Indotalent.Features.Utilities.BookingResource.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BookingResourceService BookingResourceService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Booking Resource Management + Manage rooms, equipment, or other bookable assets. +
+
+ + / + Utilities + / + Booking Resource +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedResource != null) + { + View + Edit + Remove + + } + else + { + + Add New Resource + + } +
+
+ + + + + + Resource Name + + + Booking Group + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.BookingGroupName +
+
+ +
+
+ Rows per page: + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _resources = new(); + private GetBookingResourceListResponse? _selectedResource; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedResource = null; + StateHasChanged(); + try + { + var response = await BookingResourceService.GetBookingResourceListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _resources = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _resources; + return _resources.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.BookingGroupName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedResource = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("BookingResources"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Resource Name"; + worksheet.Cell(currentRow, 2).Value = "Booking Group"; + + var headerRange = worksheet.Range(1, 1, 1, 2); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.BookingGroupName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "BookingResource_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedResource = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedResource = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedResource == null) return; + var request = await MapToUpdateRequest(_selectedResource.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedResource == null) return; + var request = await MapToUpdateRequest(_selectedResource.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await BookingResourceService.GetBookingResourceByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateBookingResourceRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + BookingGroupId = detail.BookingGroupId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedResource == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedResource.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await BookingResourceService.DeleteBookingResourceByIdAsync(_selectedResource.Id!); + if (isSuccess) + { + _selectedResource = null; + await LoadData(); + Snackbar.Add("Booking resource deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Components/_BookingResourceUpdateForm.razor b/Features/Utilities/BookingResource/Components/_BookingResourceUpdateForm.razor new file mode 100644 index 0000000..b5502a4 --- /dev/null +++ b/Features/Utilities/BookingResource/Components/_BookingResourceUpdateForm.razor @@ -0,0 +1,159 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.BookingResource +@using Indotalent.Features.Utilities.BookingResource.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BookingResourceService BookingResourceService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Booking Resource Details" : "Edit Booking Resource") + @(ReadOnly ? "Viewing booking resource specification." : "Modify existing resource information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ +
+ } + else + { + + + + Resource Name + + + + + Booking Group + + @foreach (var item in _lookupData.BookingGroups) + { + @item.Name + } + + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateBookingResourceRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateBookingResourceValidator _validator = new(); + private UpdateBookingResourceRequest _model = new(); + private LookupBookingGroupResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await BookingResourceService.GetBookingGroupLookupAsync(); + if (response != null && response.IsSuccess) + { + _lookupData = response.Value!; + } + + _model = new UpdateBookingResourceRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + BookingGroupId = Data.BookingGroupId, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + finally + { + _isDataLoading = false; + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BookingResourceService.UpdateBookingResourceAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Booking resource updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceHandler.cs b/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceHandler.cs new file mode 100644 index 0000000..7eb3893 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class CreateBookingResourceRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? BookingGroupId { get; set; } +} + +public class CreateBookingResourceResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateBookingResourceCommand(CreateBookingResourceRequest Data) : IRequest; + +public class CreateBookingResourceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateBookingResourceHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBookingResourceCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.BookingResource + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Booking Resource", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.BookingResource + { + Name = request.Data.Name, + Description = request.Data.Description, + BookingGroupId = request.Data.BookingGroupId + }; + + _context.BookingResource.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBookingResourceResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceValidator.cs b/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceValidator.cs new file mode 100644 index 0000000..8a83d11 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/CreateBookingResourceValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class CreateBookingResourceValidator : AbstractValidator +{ + public CreateBookingResourceValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.BookingGroupId) + .NotEmpty().WithMessage("Booking Group is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/DeleteBookingResourceByIdHandler.cs b/Features/Utilities/BookingResource/Cqrs/DeleteBookingResourceByIdHandler.cs new file mode 100644 index 0000000..aa3b8ef --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/DeleteBookingResourceByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class DeleteBookingResourceByIdRequest +{ + public DeleteBookingResourceByIdRequest(string id) => Id = id; + public string Id { get; set; } +} + +public record DeleteBookingResourceByIdCommand(DeleteBookingResourceByIdRequest Data) : IRequest; + +public class DeleteBookingResourceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteBookingResourceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBookingResourceByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.BookingResource + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.BookingResource.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/GetBookingResourceByIdHandler.cs b/Features/Utilities/BookingResource/Cqrs/GetBookingResourceByIdHandler.cs new file mode 100644 index 0000000..a480ea0 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/GetBookingResourceByIdHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class GetBookingResourceByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? BookingGroupId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingResourceByIdQuery(string Id) : IRequest; + +public class GetBookingResourceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBookingResourceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBookingResourceByIdQuery request, CancellationToken cancellationToken) + { + return await _context.BookingResource + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetBookingResourceByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + BookingGroupId = x.BookingGroupId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/GetBookingResourceListHandler.cs b/Features/Utilities/BookingResource/Cqrs/GetBookingResourceListHandler.cs new file mode 100644 index 0000000..5a65dde --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/GetBookingResourceListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class GetBookingResourceListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? BookingGroupName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBookingResourceListQuery() : IRequest>; + +public class GetBookingResourceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBookingResourceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBookingResourceListQuery request, CancellationToken cancellationToken) + { + return await _context.BookingResource + .AsNoTracking() + .Include(x => x.BookingGroup) + .OrderBy(x => x.Name) + .Select(x => new GetBookingResourceListResponse + { + Id = x.Id, + Name = x.Name, + BookingGroupName = x.BookingGroup != null ? x.BookingGroup.Name : string.Empty, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/LookupBookingGroupHandler.cs b/Features/Utilities/BookingResource/Cqrs/LookupBookingGroupHandler.cs new file mode 100644 index 0000000..66c1b42 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/LookupBookingGroupHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class LookupBookingGroupResponse +{ + public List BookingGroups { get; set; } = new(); +} + +public class LookupBookingGroupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupBookingGroupQuery() : IRequest; + +public class LookupBookingGroupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupBookingGroupHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupBookingGroupQuery request, CancellationToken cancellationToken) + { + var items = await _context.BookingGroup + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupBookingGroupItem + { + Id = x.Id, + Name = x.Name + }) + .ToListAsync(cancellationToken); + + return new LookupBookingGroupResponse { BookingGroups = items }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceHandler.cs b/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceHandler.cs new file mode 100644 index 0000000..79378a4 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class UpdateBookingResourceRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? BookingGroupId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateBookingResourceResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBookingResourceCommand(UpdateBookingResourceRequest Data) : IRequest; + +public class UpdateBookingResourceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateBookingResourceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBookingResourceCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.BookingResource + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Booking Resource", request.Data.Name ?? string.Empty); + } + + var entity = await _context.BookingResource + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateBookingResourceResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.BookingGroupId = request.Data.BookingGroupId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBookingResourceResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceValidator.cs b/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceValidator.cs new file mode 100644 index 0000000..19ab679 --- /dev/null +++ b/Features/Utilities/BookingResource/Cqrs/UpdateBookingResourceValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.BookingResource.Cqrs; + +public class UpdateBookingResourceValidator : AbstractValidator +{ + public UpdateBookingResourceValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.BookingGroupId) + .NotEmpty().WithMessage("Booking Group is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingScheduler/BookingSchedulerEndpoint.cs b/Features/Utilities/BookingScheduler/BookingSchedulerEndpoint.cs new file mode 100644 index 0000000..64ed202 --- /dev/null +++ b/Features/Utilities/BookingScheduler/BookingSchedulerEndpoint.cs @@ -0,0 +1,24 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.BookingScheduler.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.BookingScheduler; + +public static class BookingSchedulerEndpoint +{ + public static void MapBookingSchedulerEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/booking-scheduler").WithTags("BookingScheduler") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBookingSchedulerListQuery()); + return result.ToApiResponse("Scheduler data retrieved successfully"); + }) + .WithName("GetBookingSchedulerList") + .WithTags("BookingScheduler"); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingScheduler/BookingSchedulerService.cs b/Features/Utilities/BookingScheduler/BookingSchedulerService.cs new file mode 100644 index 0000000..940e481 --- /dev/null +++ b/Features/Utilities/BookingScheduler/BookingSchedulerService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.BookingScheduler.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.BookingScheduler; + +public class BookingSchedulerService : BaseService +{ + private readonly RestClient _client; + + public BookingSchedulerService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBookingSchedulerListAsync() + { + var request = new RestRequest("api/booking-scheduler", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingScheduler/Components/BookingSchedulerPage.razor b/Features/Utilities/BookingScheduler/Components/BookingSchedulerPage.razor new file mode 100644 index 0000000..8eb3d54 --- /dev/null +++ b/Features/Utilities/BookingScheduler/Components/BookingSchedulerPage.razor @@ -0,0 +1,208 @@ +@page "/utilities/booking-scheduler" +@using Indotalent.Features.Utilities.BookingScheduler +@using Indotalent.Features.Utilities.BookingScheduler.Cqrs +@using MudBlazor +@inject BookingSchedulerService SchedulerService + + +
+ Booking Scheduler + Visual schedule of resource allocations and time slots. +
+
+ + / + Utilities + / + Scheduler +
+
+ + + + +
+ Date Navigation + +
+
+ +
+ +
+ Summary for @(_selectedDate?.ToString("dd MMM yyyy") ?? "Today") +
+
+ Total Bookings + @GetFilteredData().Count() +
+
+ Active Resources + @GetFilteredData().Select(x => x.ResourceName).Distinct().Count() +
+
+
+
+
+ + + @if (_loading) + { +
+ +
+ } + else + { + +
+ + Timeline Details +
+ + @if (!GetFilteredData().Any()) + { +
+ + No bookings scheduled for this date. +
+ } + else + { + + @foreach (var item in GetFilteredData()) + { + + +
+ @item.StartTime?.ToString("HH:mm") + @item.EndTime?.ToString("HH:mm") +
+
+ + +
+
+
+ @item.ResourceGroupName +
+ @item.Subject +
+ + @item.ResourceName +
+
+
+ @item.Status?.ToUpper() +
+ +
+
+
+ + @if (!string.IsNullOrEmpty(item.Description)) + { + @item.Description + } + +
+
+ + @GetDuration(item.StartTime, item.EndTime) +
+
+
+
+
+ } +
+ } +
+ } +
+
+ + + +@code { + private List _data = new(); + private bool _loading = true; + private DateTime? _selectedDate = DateTime.Today; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _loading = true; + StateHasChanged(); + try + { + var response = await SchedulerService.GetBookingSchedulerListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _data = response.Value ?? new(); + } + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (!_selectedDate.HasValue) return _data; + return _data.Where(x => x.StartTime.HasValue && x.StartTime.Value.Date == _selectedDate.Value.Date); + } + + private Color GetStatusColor(string? status) => status switch + { + "Confirmed" => Color.Success, + "Done" => Color.Primary, + "OnProgress" => Color.Info, + "Cancelled" => Color.Error, + _ => Color.Default + }; + + private string GetStatusHex(string? status) => status switch + { + "Confirmed" => "#2E7D32", + "Done" => "#1565C0", + "OnProgress" => "#0288D1", + "Cancelled" => "#C62828", + _ => "#64748B" + }; + + private string GetStatusTextStyle(string? status) + { + var hex = GetStatusHex(status); + return $"font-weight: 900; color: {hex};"; + } + + private string GetCardStyle(string? status) + { + var hex = GetStatusHex(status); + return $"border: 1px solid #E2E8F0; border-left: 6px solid {hex}; background: #ffffff; box-shadow: 0 2px 4px rgba(0,0,0,0.02);"; + } + + private string GetDuration(DateTime? start, DateTime? end) + { + if (!start.HasValue || !end.HasValue) return "N/A"; + var diff = end.Value - start.Value; + return $"{(int)diff.TotalHours}h {diff.Minutes}m"; + } +} \ No newline at end of file diff --git a/Features/Utilities/BookingScheduler/Cqrs/GetBookingSchedulerListHandler.cs b/Features/Utilities/BookingScheduler/Cqrs/GetBookingSchedulerListHandler.cs new file mode 100644 index 0000000..65b9d3e --- /dev/null +++ b/Features/Utilities/BookingScheduler/Cqrs/GetBookingSchedulerListHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.BookingScheduler.Cqrs; + +public class GetBookingSchedulerListResponse +{ + public string? Id { get; set; } + public string? Subject { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? Status { get; set; } + public string? ResourceName { get; set; } + public string? ResourceGroupName { get; set; } + public string? Description { get; set; } +} + +public record GetBookingSchedulerListQuery() : IRequest>; + +public class GetBookingSchedulerListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBookingSchedulerListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBookingSchedulerListQuery request, CancellationToken cancellationToken) + { + return await _context.Booking + .AsNoTracking() + .Include(x => x.BookingResource) + .ThenInclude(x => x!.BookingGroup) + .OrderBy(x => x.StartTime) + .Select(x => new GetBookingSchedulerListResponse + { + Id = x.Id, + Subject = x.Subject, + StartTime = x.StartTime, + EndTime = x.EndTime, + Status = x.Status.GetDescription(), + ResourceName = x.BookingResource != null ? x.BookingResource.Name : "Unassigned", + ResourceGroupName = x.BookingResource != null && x.BookingResource.BookingGroup != null + ? x.BookingResource.BookingGroup.Name : "General", + Description = x.Description + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Components/ProgramManagerPage.razor b/Features/Utilities/ProgramManager/Components/ProgramManagerPage.razor new file mode 100644 index 0000000..73f8a39 --- /dev/null +++ b/Features/Utilities/ProgramManager/Components/ProgramManagerPage.razor @@ -0,0 +1,27 @@ +@page "/utilities/program-manager" +@using Indotalent.Features.Utilities.ProgramManager.Cqrs +@using Indotalent.Features.Utilities.ProgramManager.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ProgramManagerCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ProgramManagerUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_ProgramManagerDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateProgramManagerRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateProgramManagerRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Components/_ProgramManagerCreateForm.razor b/Features/Utilities/ProgramManager/Components/_ProgramManagerCreateForm.razor new file mode 100644 index 0000000..0604b38 --- /dev/null +++ b/Features/Utilities/ProgramManager/Components/_ProgramManagerCreateForm.razor @@ -0,0 +1,106 @@ +@using Indotalent.Features.Utilities.ProgramManager.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProgramManagerService ProgramManagerService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Program + Initiate a new program management record. +
+
+
+ + + + + + Program Title + + + + + Priority + + @foreach (var item in _lookup.Priorities) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Assign Resource + + @foreach (var item in _lookup.Resources) + { + @item.Name + } + + + + + Summary + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Program + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateProgramManagerValidator _validator = new(); + private CreateProgramManagerRequest _model = new() { Status = Indotalent.Data.Enums.ProgramManagerStatus.Draft, Priority = Indotalent.Data.Enums.ProgramManagerPriority.Normal }; + private ProgramManagerLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ProgramManagerService.GetLookupDataAsync(); + if (res != null && res.IsSuccess) { _lookup = res.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await ProgramManagerService.CreateProgramManagerAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Components/_ProgramManagerDataTable.razor b/Features/Utilities/ProgramManager/Components/_ProgramManagerDataTable.razor new file mode 100644 index 0000000..1da6003 --- /dev/null +++ b/Features/Utilities/ProgramManager/Components/_ProgramManagerDataTable.razor @@ -0,0 +1,261 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.ProgramManager +@using Indotalent.Features.Utilities.ProgramManager.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ProgramManagerService ProgramManagerService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Program Management + Oversee and manage programs, resources, and priorities. +
+
+ + / + Utilities + / + ProgramManager +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) { } else { Excel } + + + + @if (_isRefreshing) { + Refreshing... } else { Refresh } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New Program + + } +
+
+ + + + + + No + + + Title + + + Resource + + + Priority + + + Status + + + + + @context.AutoNumber + @context.Title + @context.ResourceName + + @context.Priority?.ToUpper() + + + @context.Status?.ToUpper() + + + + +
+
+ Rows per page: + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetProgramManagerListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await ProgramManagerService.GetProgramManagerListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _items = response.Value ?? new(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Title?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ResourceName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private Color GetPriorityColor(string? priority) => priority switch { + "Critical" => Color.Error, "High" => Color.Warning, "Normal" => Color.Info, _ => Color.Default + }; + + private Color GetStatusColor(string? status) => status switch { + "Done" => Color.Success, "OnProgress" => Color.Info, "Confirmed" => Color.Primary, "Cancelled" => Color.Error, _ => Color.Default + }; + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Programs"); + worksheet.Cell(1, 1).Value = "No"; worksheet.Cell(1, 2).Value = "Title"; + worksheet.Cell(1, 3).Value = "Resource"; worksheet.Cell(1, 4).Value = "Priority"; + worksheet.Cell(1, 5).Value = "Status"; + var row = 1; + foreach (var item in GetFilteredData()) + { + row++; + worksheet.Cell(row, 1).Value = item.AutoNumber; worksheet.Cell(row, 2).Value = item.Title; + worksheet.Cell(row, 3).Value = item.ResourceName; worksheet.Cell(row, 4).Value = item.Priority; + worksheet.Cell(row, 5).Value = item.Status; + } + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Program_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; } + } + + private void OnSearchClick() { _skip = 0; _selectedItem = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + private void OnPageChanged(int page) { _skip = (page - 1) * _top; _selectedItem = null; } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; } + + private async Task InvokeEdit() { if (_selectedItem == null) return; var request = await MapToUpdateRequest(_selectedItem.Id!); if (request != null) await OnEdit.InvokeAsync(request); } + private async Task InvokeView() { if (_selectedItem == null) return; var request = await MapToUpdateRequest(_selectedItem.Id!); if (request != null) await OnView.InvokeAsync(request); } + + private async Task MapToUpdateRequest(string id) + { + var response = await ProgramManagerService.GetProgramManagerByIdAsync(id); + if (response != null && response.IsSuccess) + { + var d = response.Value!; + return new UpdateProgramManagerRequest { Id = d.Id, Title = d.Title, Summary = d.Summary, Status = d.Status, Priority = d.Priority, ProgramManagerResourceId = d.ProgramManagerResourceId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.Title } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) { if (await ProgramManagerService.DeleteProgramManagerAsync(_selectedItem.Id!)) { await LoadData(); Snackbar.Add("Deleted successfully", Severity.Success); } } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Components/_ProgramManagerUpdateForm.razor b/Features/Utilities/ProgramManager/Components/_ProgramManagerUpdateForm.razor new file mode 100644 index 0000000..d963369 --- /dev/null +++ b/Features/Utilities/ProgramManager/Components/_ProgramManagerUpdateForm.razor @@ -0,0 +1,140 @@ +@using Indotalent.Features.Utilities.ProgramManager.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@using Indotalent.ConfigBackEnd.Extensions +@inject ProgramManagerService ProgramManagerService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Program Details" : "Edit Program") + @(ReadOnly ? "Viewing program details." : "Update program information and status.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Program Title + + + + + Priority + + @foreach (var item in _lookup.Priorities) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Assigned Resource + + @foreach (var item in _lookup.Resources) + { + @item.Name + } + + + + + Summary + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + @if (_processing) { + + Updating... + } else + { + Save Changes + } + + } +
+
+} +
+ +@code { + [Parameter] public UpdateProgramManagerRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateProgramManagerValidator _validator = new(); + private UpdateProgramManagerRequest _model = new(); + private ProgramManagerLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + var res = await ProgramManagerService.GetLookupDataAsync(); + if (res != null && res.IsSuccess) { _lookup = res.Value!; } + _model = Data; _isDataLoading = false; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await ProgramManagerService.UpdateProgramManagerAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerHandler.cs b/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerHandler.cs new file mode 100644 index 0000000..e91ce7b --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class CreateProgramManagerRequest +{ + public string? Title { get; set; } + public string? Summary { get; set; } + public ProgramManagerStatus Status { get; set; } + public ProgramManagerPriority Priority { get; set; } + public string? ProgramManagerResourceId { get; set; } +} + +public class CreateProgramManagerResponse +{ + public string? Id { get; set; } + public string? Title { get; set; } +} + +public record CreateProgramManagerCommand(CreateProgramManagerRequest Data) : IRequest; + +public class CreateProgramManagerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateProgramManagerHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateProgramManagerCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.ProgramManager + { + Title = request.Data.Title, + Summary = request.Data.Summary, + Status = request.Data.Status, + Priority = request.Data.Priority, + ProgramManagerResourceId = request.Data.ProgramManagerResourceId + }; + + _context.ProgramManager.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateProgramManagerResponse + { + Id = entity.Id, + Title = entity.Title + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerValidator.cs b/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerValidator.cs new file mode 100644 index 0000000..0cfe07a --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/CreateProgramManagerValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class CreateProgramManagerValidator : AbstractValidator +{ + public CreateProgramManagerValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.ProgramManagerResourceId).NotEmpty().WithMessage("Resource is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/DeleteProgramManagerHandler.cs b/Features/Utilities/ProgramManager/Cqrs/DeleteProgramManagerHandler.cs new file mode 100644 index 0000000..5f8c0c7 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/DeleteProgramManagerHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public record DeleteProgramManagerCommand(string Id) : IRequest; + +public class DeleteProgramManagerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteProgramManagerHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteProgramManagerCommand request, CancellationToken cancellationToken) + { + var entity = await _context.ProgramManager + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.ProgramManager.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerByIdHandler.cs b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerByIdHandler.cs new file mode 100644 index 0000000..2bc86b8 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerByIdHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class GetProgramManagerByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Summary { get; set; } + public ProgramManagerStatus Status { get; set; } + public ProgramManagerPriority Priority { get; set; } + public string? ProgramManagerResourceId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProgramManagerByIdQuery(string Id) : IRequest; + +public class GetProgramManagerByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProgramManagerByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProgramManagerByIdQuery request, CancellationToken cancellationToken) + { + return await _context.ProgramManager + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProgramManagerByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + Summary = x.Summary, + Status = x.Status, + Priority = x.Priority, + ProgramManagerResourceId = x.ProgramManagerResourceId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerListHandler.cs b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerListHandler.cs new file mode 100644 index 0000000..4792e39 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class GetProgramManagerListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? ResourceName { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProgramManagerListQuery() : IRequest>; + +public class GetProgramManagerListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetProgramManagerListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetProgramManagerListQuery request, CancellationToken cancellationToken) + { + return await _context.ProgramManager + .AsNoTracking() + .Include(x => x.ProgramManagerResource) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetProgramManagerListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + ResourceName = x.ProgramManagerResource != null ? x.ProgramManagerResource.Name : string.Empty, + Status = x.Status.GetDescription(), + Priority = x.Priority.GetDescription(), + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerLookupHandler.cs b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerLookupHandler.cs new file mode 100644 index 0000000..ad0e364 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/GetProgramManagerLookupHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Enums; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class ProgramManagerLookupResponse +{ + public List Resources { get; set; } = new(); + public List Statuses { get; set; } = new(); + public List Priorities { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetProgramManagerLookupQuery() : IRequest; + +public class GetProgramManagerLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProgramManagerLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProgramManagerLookupQuery request, CancellationToken cancellationToken) + { + var response = new ProgramManagerLookupResponse(); + + response.Resources = await _context.ProgramManagerResource + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(ProgramManagerStatus)) + .Cast() + .Select(e => new LookupItem { Value = (int)e, Name = e.GetDescription() }) + .ToList(); + + response.Priorities = Enum.GetValues(typeof(ProgramManagerPriority)) + .Cast() + .Select(e => new LookupItem { Value = (int)e, Name = e.GetDescription() }) + .ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerHandler.cs b/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerHandler.cs new file mode 100644 index 0000000..e5d4661 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class UpdateProgramManagerRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Summary { get; set; } + public ProgramManagerStatus Status { get; set; } + public ProgramManagerPriority Priority { get; set; } + public string? ProgramManagerResourceId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateProgramManagerResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateProgramManagerCommand(UpdateProgramManagerRequest Data) : IRequest; + +public class UpdateProgramManagerHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProgramManagerHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProgramManagerCommand request, CancellationToken cancellationToken) + { + var entity = await _context.ProgramManager + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateProgramManagerResponse { Id = request.Data.Id, Success = false }; + } + + entity.Title = request.Data.Title; + entity.Summary = request.Data.Summary; + entity.Status = request.Data.Status; + entity.Priority = request.Data.Priority; + entity.ProgramManagerResourceId = request.Data.ProgramManagerResourceId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProgramManagerResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerValidator.cs b/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerValidator.cs new file mode 100644 index 0000000..215f2e8 --- /dev/null +++ b/Features/Utilities/ProgramManager/Cqrs/UpdateProgramManagerValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.ProgramManager.Cqrs; + +public class UpdateProgramManagerValidator : AbstractValidator +{ + public UpdateProgramManagerValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.ProgramManagerResourceId).NotEmpty().WithMessage("Resource is required"); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/ProgramManagerEndpoint.cs b/Features/Utilities/ProgramManager/ProgramManagerEndpoint.cs new file mode 100644 index 0000000..9b188a7 --- /dev/null +++ b/Features/Utilities/ProgramManager/ProgramManagerEndpoint.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.ProgramManager.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.ProgramManager; + +public static class ProgramManagerEndpoint +{ + public static void MapProgramManagerEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/program-manager").WithTags("ProgramManager") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetProgramManagerLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetProgramManagerLookup"); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetProgramManagerListQuery()); + return result.ToApiResponse("Program list retrieved successfully"); + }) + .WithName("GetProgramManagerList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProgramManagerByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Program detail retrieved successfully" + : "Program not found"); + }) + .WithName("GetProgramManagerById"); + + group.MapPost("/", async (CreateProgramManagerRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateProgramManagerCommand(request)); + return result.ToApiResponse("Program created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateProgramManager"); + + group.MapPost("/update", async (UpdateProgramManagerRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProgramManagerCommand(request)); + return result.ToApiResponse("Program updated successfully"); + }) + .WithName("UpdateProgramManager"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteProgramManagerCommand(id)); + return result.ToApiResponse("Program deleted successfully"); + }) + .WithName("DeleteProgramManager"); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramManager/ProgramManagerService.cs b/Features/Utilities/ProgramManager/ProgramManagerService.cs new file mode 100644 index 0000000..7652484 --- /dev/null +++ b/Features/Utilities/ProgramManager/ProgramManagerService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.ProgramManager.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.ProgramManager; + +public class ProgramManagerService : BaseService +{ + private readonly RestClient _client; + + public ProgramManagerService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetLookupDataAsync() + { + var request = new RestRequest("api/program-manager/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task>?> GetProgramManagerListAsync() + { + var request = new RestRequest("api/program-manager", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetProgramManagerByIdAsync(string id) + { + var request = new RestRequest($"api/program-manager/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateProgramManagerAsync(CreateProgramManagerRequest data) + { + var request = new RestRequest("api/program-manager", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateProgramManagerAsync(UpdateProgramManagerRequest data) + { + var request = new RestRequest("api/program-manager/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteProgramManagerAsync(string id) + { + var request = new RestRequest($"api/program-manager/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Components/ProgramResourcePage.razor b/Features/Utilities/ProgramResource/Components/ProgramResourcePage.razor new file mode 100644 index 0000000..6e351ef --- /dev/null +++ b/Features/Utilities/ProgramResource/Components/ProgramResourcePage.razor @@ -0,0 +1,28 @@ +@page "/utilities/program-resource" +@using Indotalent.Features.Utilities.ProgramResource.Cqrs +@using Indotalent.Features.Utilities.ProgramResource.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ProgramResourceCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ProgramResourceUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_ProgramResourceDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateProgramResourceRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateProgramResourceRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Components/_ProgramResourceCreateForm.razor b/Features/Utilities/ProgramResource/Components/_ProgramResourceCreateForm.razor new file mode 100644 index 0000000..21cf96d --- /dev/null +++ b/Features/Utilities/ProgramResource/Components/_ProgramResourceCreateForm.razor @@ -0,0 +1,95 @@ +@using Indotalent.Features.Utilities.ProgramResource.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ProgramResourceService ProgramResourceService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Resource + Create a new master resource for program manager. +
+
+
+ + + + + + Resource Name + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Resource + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateProgramResourceValidator _validator = new(); + private CreateProgramResourceRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await ProgramResourceService.CreateProgramResourceAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Resource created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Components/_ProgramResourceDataTable.razor b/Features/Utilities/ProgramResource/Components/_ProgramResourceDataTable.razor new file mode 100644 index 0000000..86ddc22 --- /dev/null +++ b/Features/Utilities/ProgramResource/Components/_ProgramResourceDataTable.razor @@ -0,0 +1,286 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.ProgramResource +@using Indotalent.Features.Utilities.ProgramResource.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ProgramResourceService ProgramResourceService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Program Resource + Manage master resources for program management. +
+
+ + / + Utilities + / + ProgramResource +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New Resource + + } +
+
+ + + + + + Resource Name + + Description + + + + + + + @context.Name + + @context.Description + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetProgramResourceListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await ProgramResourceService.GetProgramResourceListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedItem = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + await Task.Delay(500); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Resources"); + worksheet.Cell(1, 1).Value = "Resource Name"; + worksheet.Cell(1, 2).Value = "Description"; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Description; + } + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "ProgramResource_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Exported successfully", Severity.Success); + } + } + } + finally { _isExporting = false; } + } + + private void OnPageChanged(int page) { _skip = (page - 1) * _top; _selectedItem = null; } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var response = await ProgramResourceService.GetProgramResourceByIdAsync(_selectedItem.Id!); + if (response != null && response.IsSuccess) + { + await OnEdit.InvokeAsync(new UpdateProgramResourceRequest { Id = response.Value!.Id, Name = response.Value.Name, Description = response.Value.Description }); + } + } + + private async Task InvokeView() + { + if (_selectedItem == null) return; + var response = await ProgramResourceService.GetProgramResourceByIdAsync(_selectedItem.Id!); + if (response != null && response.IsSuccess) + { + await OnView.InvokeAsync(new UpdateProgramResourceRequest { Id = response.Value!.Id, Name = response.Value.Name, Description = response.Value.Description }); + } + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + if (await ProgramResourceService.DeleteProgramResourceAsync(_selectedItem.Id!)) + { + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Components/_ProgramResourceUpdateForm.razor b/Features/Utilities/ProgramResource/Components/_ProgramResourceUpdateForm.razor new file mode 100644 index 0000000..4d25843 --- /dev/null +++ b/Features/Utilities/ProgramResource/Components/_ProgramResourceUpdateForm.razor @@ -0,0 +1,142 @@ +@using Indotalent.Features.Utilities.ProgramResource.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@using Indotalent.ConfigBackEnd.Extensions +@inject ProgramResourceService ProgramResourceService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Resource Details" : "Edit Resource") + @(ReadOnly ? "Viewing resource details." : "Modify existing resource information.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Resource Name + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_auditData?.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_auditData?.CreatedBy) ? _auditData?.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_auditData?.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_auditData?.UpdatedBy) ? _auditData?.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateProgramResourceRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateProgramResourceValidator _validator = new(); + private UpdateProgramResourceRequest _model = new(); + private GetProgramResourceByIdResponse? _auditData; + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await ProgramResourceService.GetProgramResourceByIdAsync(Data.Id!); + if (response != null && response.IsSuccess) + { + _auditData = response.Value; + _model = new UpdateProgramResourceRequest + { + Id = _auditData!.Id, + Name = _auditData.Name, + Description = _auditData.Description + }; + } + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await ProgramResourceService.UpdateProgramResourceAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Resource updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceHandler.cs b/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceHandler.cs new file mode 100644 index 0000000..f2df900 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class CreateProgramResourceRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateProgramResourceResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateProgramResourceCommand(CreateProgramResourceRequest Data) : IRequest; + +public class CreateProgramResourceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateProgramResourceHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateProgramResourceCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.ProgramManagerResource + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.ProgramManagerResource.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateProgramResourceResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceValidator.cs b/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceValidator.cs new file mode 100644 index 0000000..f9b1881 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/CreateProgramResourceValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class CreateProgramResourceValidator : AbstractValidator +{ + public CreateProgramResourceValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/DeleteProgramResourceHandler.cs b/Features/Utilities/ProgramResource/Cqrs/DeleteProgramResourceHandler.cs new file mode 100644 index 0000000..442abd9 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/DeleteProgramResourceHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public record DeleteProgramResourceCommand(string Id) : IRequest; + +public class DeleteProgramResourceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteProgramResourceHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteProgramResourceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.ProgramManagerResource + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.ProgramManagerResource.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceByIdHandler.cs b/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceByIdHandler.cs new file mode 100644 index 0000000..e77f814 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceByIdHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class GetProgramResourceByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetProgramResourceByIdQuery(string Id) : IRequest; + +public class GetProgramResourceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProgramResourceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProgramResourceByIdQuery request, CancellationToken cancellationToken) + { + return await _context.ProgramManagerResource + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProgramResourceByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceListHandler.cs b/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceListHandler.cs new file mode 100644 index 0000000..faef5a4 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/GetProgramResourceListHandler.cs @@ -0,0 +1,35 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class GetProgramResourceListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + +public record GetProgramResourceListQuery() : IRequest>; + +public class GetProgramResourceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetProgramResourceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetProgramResourceListQuery request, CancellationToken cancellationToken) + { + return await _context.ProgramManagerResource + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetProgramResourceListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceHandler.cs b/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceHandler.cs new file mode 100644 index 0000000..1043af1 --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class UpdateProgramResourceRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class UpdateProgramResourceResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateProgramResourceCommand(UpdateProgramResourceRequest Data) : IRequest; + +public class UpdateProgramResourceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProgramResourceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProgramResourceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.ProgramManagerResource + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateProgramResourceResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProgramResourceResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceValidator.cs b/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceValidator.cs new file mode 100644 index 0000000..74e52fb --- /dev/null +++ b/Features/Utilities/ProgramResource/Cqrs/UpdateProgramResourceValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.ProgramResource.Cqrs; + +public class UpdateProgramResourceValidator : AbstractValidator +{ + public UpdateProgramResourceValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/ProgramResourceEndpoint.cs b/Features/Utilities/ProgramResource/ProgramResourceEndpoint.cs new file mode 100644 index 0000000..f0f1595 --- /dev/null +++ b/Features/Utilities/ProgramResource/ProgramResourceEndpoint.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.ProgramResource.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.ProgramResource; + +public static class ProgramResourceEndpoint +{ + public static void MapProgramResourceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/program-resource").WithTags("ProgramResource") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetProgramResourceListQuery()); + return result.ToApiResponse("Resource list retrieved successfully"); + }) + .WithName("GetProgramResourceList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProgramResourceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Resource detail retrieved successfully" + : "Resource not found"); + }) + .WithName("GetProgramResourceById"); + + group.MapPost("/", async (CreateProgramResourceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateProgramResourceCommand(request)); + return result.ToApiResponse("Resource created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateProgramResource"); + + group.MapPost("/update", async (UpdateProgramResourceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProgramResourceCommand(request)); + return result.ToApiResponse("Resource updated successfully"); + }) + .WithName("UpdateProgramResource"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteProgramResourceCommand(id)); + return result.ToApiResponse("Resource deleted successfully"); + }) + .WithName("DeleteProgramResource"); + } +} \ No newline at end of file diff --git a/Features/Utilities/ProgramResource/ProgramResourceService.cs b/Features/Utilities/ProgramResource/ProgramResourceService.cs new file mode 100644 index 0000000..9ce3b27 --- /dev/null +++ b/Features/Utilities/ProgramResource/ProgramResourceService.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.ProgramResource.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.ProgramResource; + +public class ProgramResourceService : BaseService +{ + private readonly RestClient _client; + + public ProgramResourceService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetProgramResourceListAsync() + { + var request = new RestRequest("api/program-resource", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetProgramResourceByIdAsync(string id) + { + var request = new RestRequest($"api/program-resource/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateProgramResourceAsync(CreateProgramResourceRequest data) + { + var request = new RestRequest("api/program-resource", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateProgramResourceAsync(UpdateProgramResourceRequest data) + { + var request = new RestRequest("api/program-resource/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteProgramResourceAsync(string id) + { + var request = new RestRequest($"api/program-resource/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/TodoPage.razor b/Features/Utilities/Todo/Components/TodoPage.razor new file mode 100644 index 0000000..af3b8d3 --- /dev/null +++ b/Features/Utilities/Todo/Components/TodoPage.razor @@ -0,0 +1,53 @@ +@page "/utilities/todo" +@using Indotalent.Features.Utilities.Todo +@using Indotalent.Features.Utilities.Todo.Cqrs +@using Indotalent.Features.Utilities.Todo.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TodoCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TodoUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_TodoDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTodoRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTodoRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoCreateForm.razor b/Features/Utilities/Todo/Components/_TodoCreateForm.razor new file mode 100644 index 0000000..1fe8f6f --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoCreateForm.razor @@ -0,0 +1,137 @@ +@using Indotalent.Features.Utilities.Todo +@using Indotalent.Features.Utilities.Todo.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TodoService TodoService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Todo + Create a new master todo and its items. +
+
+
+ + + + + + Todo Name + + + + + Start Date + + + + + Start Time + + + + + End Date + + + + + End Time + + + + + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Todo + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTodoValidator _validator = new(); + private CreateTodoRequest _model = new(); + private bool _processing = false; + + private DateTime? _startDate = DateTime.Today; + private TimeSpan? _startTime = DateTime.Now.TimeOfDay; + private DateTime? _endDate = DateTime.Today; + private TimeSpan? _endTime = DateTime.Now.TimeOfDay.Add(TimeSpan.FromHours(1)); + + private async Task Submit() + { + if (_startDate.HasValue && _startTime.HasValue) + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + + if (_endDate.HasValue && _endTime.HasValue) + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TodoService.CreateTodoAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Todo created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoDataTable.razor b/Features/Utilities/Todo/Components/_TodoDataTable.razor new file mode 100644 index 0000000..40798aa --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoDataTable.razor @@ -0,0 +1,401 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.Todo +@using Indotalent.Features.Utilities.Todo.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TodoService TodoService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Todo Management + Manage your daily tasks and activities. +
+
+ + / + Utilities + / + Todo +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedTodo != null) + { + View + Edit + Remove + + } + else + { + + Add New Todo + + } +
+
+ + + + + + No + + + Todo Name + + Start Time + Status + Description + + + + + + @context.AutoNumber + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @DateTimeExtensions.ToString(context.StartTime) + + @if (context.IsCompleted) + { + COMPLETED + } + else + { + PENDING + } + + @context.Description +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + Next + +
+
+
+ + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _todos = new(); + private GetTodoListResponse? _selectedTodo; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedTodo = null; + StateHasChanged(); + try + { + var response = await TodoService.GetTodoListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _todos = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _todos; + return _todos.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedTodo = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Todos"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Auto Number"; + worksheet.Cell(currentRow, 2).Value = "Todo Name"; + worksheet.Cell(currentRow, 3).Value = "Start Time"; + worksheet.Cell(currentRow, 4).Value = "End Time"; + worksheet.Cell(currentRow, 5).Value = "Status"; + worksheet.Cell(currentRow, 6).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.StartTime?.ToString("yyyy-MM-dd HH:mm"); + worksheet.Cell(currentRow, 4).Value = item.EndTime?.ToString("yyyy-MM-dd HH:mm"); + worksheet.Cell(currentRow, 5).Value = item.IsCompleted ? "Completed" : "Pending"; + worksheet.Cell(currentRow, 6).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Todo_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedTodo = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedTodo = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedTodo == null) return; + var request = await MapToUpdateRequest(_selectedTodo.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedTodo == null) return; + var request = await MapToUpdateRequest(_selectedTodo.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await TodoService.GetTodoByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateTodoRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + StartTime = detail.StartTime, + EndTime = detail.EndTime, + IsCompleted = detail.IsCompleted, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedTodo == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedTodo.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await TodoService.DeleteTodoByIdAsync(_selectedTodo.Id!); + if (isSuccess) + { + _selectedTodo = null; + await LoadData(); + Snackbar.Add("Todo deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoItemCreateForm.razor b/Features/Utilities/Todo/Components/_TodoItemCreateForm.razor new file mode 100644 index 0000000..1f219d0 --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoItemCreateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Utilities.Todo.Cqrs +@using MudBlazor +@inject TodoService TodoService +@inject ISnackbar Snackbar + + + + + + Item Name + + + + Start Date + + + + Start Time + + + + End Date + + + + End Time + + + + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string TodoId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateTodoItemRequest _model = new(); + private bool _processing = false; + + private DateTime? _startDate = DateTime.Today; + private TimeSpan? _startTime = DateTime.Now.TimeOfDay; + private DateTime? _endDate = DateTime.Today; + private TimeSpan? _endTime = DateTime.Now.TimeOfDay.Add(TimeSpan.FromHours(1)); + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + if (_startDate.HasValue && _startTime.HasValue) + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + + if (_endDate.HasValue && _endTime.HasValue) + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + _model.TodoId = TodoId; + try + { + var response = await TodoService.CreateTodoItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Item added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoItemDataTable.razor b/Features/Utilities/Todo/Components/_TodoItemDataTable.razor new file mode 100644 index 0000000..22f61cf --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoItemDataTable.razor @@ -0,0 +1,101 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.Todo +@using Indotalent.Features.Utilities.Todo.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject TodoService TodoService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Todo Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Name + Start Time + Status + Description + @if (!ReadOnly) + { + Actions + } + + + @context.Name + @DateTimeExtensions.ToString(context.StartTime) + + @if (context.IsCompleted) + { + COMPLETED + } + else + { + PENDING + } + + @context.Description + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string TodoId { get; set; } = string.Empty; + [Parameter] public List Items { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["TodoId"] = TodoId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TodoItemCreateForm>("Add Todo Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(TodoItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TodoItemUpdateForm>("Edit Todo Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(TodoItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove {item.Name}?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await TodoService.DeleteTodoItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Item removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoItemUpdateForm.razor b/Features/Utilities/Todo/Components/_TodoItemUpdateForm.razor new file mode 100644 index 0000000..98cd95b --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoItemUpdateForm.razor @@ -0,0 +1,107 @@ +@using Indotalent.Features.Utilities.Todo.Cqrs +@using MudBlazor +@inject TodoService TodoService +@inject ISnackbar Snackbar + + + + + + Item Name + + + + Start Date + + + + Start Time + + + + End Date + + + + End Time + + + + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public TodoItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateTodoItemRequest _model = new(); + private bool _processing = false; + + private DateTime? _startDate; + private TimeSpan? _startTime; + private DateTime? _endDate; + private TimeSpan? _endTime; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Name = Data.Name; + _model.Description = Data.Description; + _model.IsCompleted = Data.IsCompleted; + _model.StartTime = Data.StartTime; + _model.EndTime = Data.EndTime; + + _startDate = _model.StartTime?.Date; + _startTime = _model.StartTime?.TimeOfDay; + _endDate = _model.EndTime?.Date; + _endTime = _model.EndTime?.TimeOfDay; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + if (_startDate.HasValue && _startTime.HasValue) + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + + if (_endDate.HasValue && _endTime.HasValue) + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TodoService.UpdateTodoItemAsync(_model); + await Task.Delay(500); + if (response != null && response.Value!.Success) + { + Snackbar.Add("Item updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Components/_TodoUpdateForm.razor b/Features/Utilities/Todo/Components/_TodoUpdateForm.razor new file mode 100644 index 0000000..62e54f1 --- /dev/null +++ b/Features/Utilities/Todo/Components/_TodoUpdateForm.razor @@ -0,0 +1,208 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Utilities.Todo +@using Indotalent.Features.Utilities.Todo.Cqrs +@using Indotalent.Features.Utilities.Todo.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TodoService TodoService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Todo Details" : "Edit Todo") + @(ReadOnly ? "Viewing todo specification." : "Modify existing todo information.") +
+
+
+ + + + + + Todo Name + + + + + Start Date + + + + + Start Time + + + + + End Date + + + + + End Time + + + + + + + + + Description + + + + + <_TodoItemDataTable Items="_items" TodoId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateTodoRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateTodoValidator _validator = new(); + private UpdateTodoRequest _model = new(); + private List _items = new(); + private bool _processing = false; + + private DateTime? _startDate; + private TimeSpan? _startTime; + private DateTime? _endDate; + private TimeSpan? _endTime; + + protected override async Task OnInitializedAsync() + { + _model = new UpdateTodoRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + StartTime = Data.StartTime, + EndTime = Data.EndTime, + IsCompleted = Data.IsCompleted, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + _startDate = _model.StartTime?.Date; + _startTime = _model.StartTime?.TimeOfDay; + _endDate = _model.EndTime?.Date; + _endTime = _model.EndTime?.TimeOfDay; + + await RefreshItems(); + } + + private async Task RefreshItems() + { + var response = await TodoService.GetTodoByIdAsync(_model.Id!); + if (response != null && response.IsSuccess) + { + _items = response.Value?.TodoItems ?? new(); + } + } + + private async Task Submit() + { + if (ReadOnly) return; + + if (_startDate.HasValue && _startTime.HasValue) + _model.StartTime = _startDate.Value.Date.Add(_startTime.Value); + + if (_endDate.HasValue && _endTime.HasValue) + _model.EndTime = _endDate.Value.Date.Add(_endTime.Value); + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TodoService.UpdateTodoAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Todo updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/CreateTodoHandler.cs b/Features/Utilities/Todo/Cqrs/CreateTodoHandler.cs new file mode 100644 index 0000000..f217f74 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/CreateTodoHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class CreateTodoRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } +} + +public class CreateTodoResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateTodoCommand(CreateTodoRequest Data) : IRequest; + +public class CreateTodoHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTodoHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTodoCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Booking); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Todo + { + AutoNumber = autoNo, + Name = request.Data.Name, + Description = request.Data.Description, + StartTime = request.Data.StartTime, + EndTime = request.Data.EndTime, + IsCompleted = request.Data.IsCompleted + }; + + _context.Todo.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTodoResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/CreateTodoItemHandler.cs b/Features/Utilities/Todo/Cqrs/CreateTodoItemHandler.cs new file mode 100644 index 0000000..956184a --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/CreateTodoItemHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class CreateTodoItemRequest +{ + public string? TodoId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } +} + +public class CreateTodoItemResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateTodoItemCommand(CreateTodoItemRequest Data) : IRequest; + +public class CreateTodoItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTodoItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.TodoItem + { + TodoId = request.Data.TodoId, + Name = request.Data.Name, + Description = request.Data.Description, + StartTime = request.Data.StartTime, + EndTime = request.Data.EndTime, + IsCompleted = request.Data.IsCompleted + }; + + _context.TodoItem.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTodoItemResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/CreateTodoValidator.cs b/Features/Utilities/Todo/Cqrs/CreateTodoValidator.cs new file mode 100644 index 0000000..04d8933 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/CreateTodoValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class CreateTodoValidator : AbstractValidator +{ + public CreateTodoValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/DeleteTodoByIdHandler.cs b/Features/Utilities/Todo/Cqrs/DeleteTodoByIdHandler.cs new file mode 100644 index 0000000..d2293f7 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/DeleteTodoByIdHandler.cs @@ -0,0 +1,36 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class DeleteTodoByIdRequest +{ + public DeleteTodoByIdRequest(string id) => Id = id; + public string Id { get; set; } +} + +public record DeleteTodoByIdCommand(DeleteTodoByIdRequest Data) : IRequest; + +public class DeleteTodoByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTodoByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTodoByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Todo + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.Todo.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/DeleteTodoItemHandler.cs b/Features/Utilities/Todo/Cqrs/DeleteTodoItemHandler.cs new file mode 100644 index 0000000..5bce3f9 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/DeleteTodoItemHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public record DeleteTodoItemCommand(string Id) : IRequest; + +public class DeleteTodoItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTodoItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItem + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.TodoItem.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/GetTodoByIdHandler.cs b/Features/Utilities/Todo/Cqrs/GetTodoByIdHandler.cs new file mode 100644 index 0000000..0d0b001 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/GetTodoByIdHandler.cs @@ -0,0 +1,74 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class TodoItemResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } +} + +public class GetTodoByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List TodoItems { get; set; } = new List(); +} + +public record GetTodoByIdQuery(string Id) : IRequest; + +public class GetTodoByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTodoByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTodoByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Todo + .AsNoTracking() + .Include(x => x.TodoItemList) + .Where(x => x.Id == request.Id) + .Select(x => new GetTodoByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + Description = x.Description, + StartTime = x.StartTime, + EndTime = x.EndTime, + IsCompleted = x.IsCompleted, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + TodoItems = x.TodoItemList + .OrderBy(i => i.StartTime) + .Select(i => new TodoItemResponse + { + Id = i.Id, + Name = i.Name, + Description = i.Description, + StartTime = i.StartTime, + EndTime = i.EndTime, + IsCompleted = i.IsCompleted + }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/GetTodoListHandler.cs b/Features/Utilities/Todo/Cqrs/GetTodoListHandler.cs new file mode 100644 index 0000000..db842a5 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/GetTodoListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class GetTodoListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTodoListQuery() : IRequest>; + +public class GetTodoListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTodoListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTodoListQuery request, CancellationToken cancellationToken) + { + return await _context.Todo + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetTodoListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + Description = x.Description, + StartTime = x.StartTime, + EndTime = x.EndTime, + IsCompleted = x.IsCompleted, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/UpdateTodoHandler.cs b/Features/Utilities/Todo/Cqrs/UpdateTodoHandler.cs new file mode 100644 index 0000000..304cce0 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/UpdateTodoHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class UpdateTodoRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTodoResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTodoCommand(UpdateTodoRequest Data) : IRequest; + +public class UpdateTodoHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTodoHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTodoCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Todo + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTodoResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.StartTime = request.Data.StartTime; + entity.EndTime = request.Data.EndTime; + entity.IsCompleted = request.Data.IsCompleted; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTodoResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/UpdateTodoItemHandler.cs b/Features/Utilities/Todo/Cqrs/UpdateTodoItemHandler.cs new file mode 100644 index 0000000..86f9d98 --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/UpdateTodoItemHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class UpdateTodoItemRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public bool IsCompleted { get; set; } +} + +public class UpdateTodoItemResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTodoItemCommand(UpdateTodoItemRequest Data) : IRequest; + +public class UpdateTodoItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTodoItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItem + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTodoItemResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.StartTime = request.Data.StartTime; + entity.EndTime = request.Data.EndTime; + entity.IsCompleted = request.Data.IsCompleted; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTodoItemResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/Cqrs/UpdateTodoValidator.cs b/Features/Utilities/Todo/Cqrs/UpdateTodoValidator.cs new file mode 100644 index 0000000..e92179f --- /dev/null +++ b/Features/Utilities/Todo/Cqrs/UpdateTodoValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Utilities.Todo.Cqrs; + +public class UpdateTodoValidator : AbstractValidator +{ + public UpdateTodoValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/TodoEndpoint.cs b/Features/Utilities/Todo/TodoEndpoint.cs new file mode 100644 index 0000000..cc13bd7 --- /dev/null +++ b/Features/Utilities/Todo/TodoEndpoint.cs @@ -0,0 +1,98 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Utilities.Todo.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Utilities.Todo; + +public static class TodoEndpoint +{ + public static void MapTodoEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/todo").WithTags("Todos") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTodoListQuery()); + return result.ToApiResponse("Todo list retrieved successfully"); + }) + .WithName("GetTodoList") + .WithTags("Todos"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTodoByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Todo detail retrieved successfully" + : $"Todo with ID {id} not found"); + }) + .WithName("GetTodoById") + .WithTags("Todos"); + + group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTodoCommand(request)); + return result.ToApiResponse("Todo has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTodo") + .WithTags("Todos"); + + group.MapPost("/update", async (UpdateTodoRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTodoCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The todo data could not be found."); + } + return result.ToApiResponse("Todo has been updated successfully"); + }) + .WithName("UpdateTodo") + .WithTags("Todos"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTodoByIdCommand(new DeleteTodoByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The todo data could not be found."); + } + return true.ToApiResponse("Todo has been deleted successfully"); + }) + .WithName("DeleteTodoById") + .WithTags("Todos"); + + group.MapPost("/todo-item", async (CreateTodoItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTodoItemCommand(request)); + return result.ToApiResponse("Todo item has been added successfully"); + }) + .WithName("CreateTodoItemChild") + .WithTags("Todos"); + + group.MapPost("/todo-item/update", async (UpdateTodoItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTodoItemCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Todo item not found."); + } + return result.ToApiResponse("Todo item has been updated successfully"); + }) + .WithName("UpdateTodoItemChild") + .WithTags("Todos"); + + group.MapPost("/todo-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTodoItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Todo item not found."); + } + return true.ToApiResponse("Todo item has been removed successfully"); + }) + .WithName("DeleteTodoItemChild") + .WithTags("Todos"); + } +} \ No newline at end of file diff --git a/Features/Utilities/Todo/TodoService.cs b/Features/Utilities/Todo/TodoService.cs new file mode 100644 index 0000000..4c731e2 --- /dev/null +++ b/Features/Utilities/Todo/TodoService.cs @@ -0,0 +1,80 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Utilities.Todo.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Utilities.Todo; + +public class TodoService : BaseService +{ + private readonly RestClient _client; + + public TodoService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTodoListAsync() + { + var request = new RestRequest("api/todo", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTodoByIdAsync(string id) + { + var request = new RestRequest($"api/todo/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTodoAsync(CreateTodoRequest data) + { + var request = new RestRequest("api/todo", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTodoByIdAsync(string id) + { + var request = new RestRequest($"api/todo/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTodoAsync(UpdateTodoRequest data) + { + var request = new RestRequest("api/todo/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTodoItemAsync(CreateTodoItemRequest data) + { + var request = new RestRequest("api/todo/todo-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTodoItemAsync(UpdateTodoItemRequest data) + { + var request = new RestRequest("api/todo/todo-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTodoItemAsync(string id) + { + var request = new RestRequest($"api/todo/todo-item/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Utilities/UtilitiesPage.razor b/Features/Utilities/UtilitiesPage.razor new file mode 100644 index 0000000..d82ce91 --- /dev/null +++ b/Features/Utilities/UtilitiesPage.razor @@ -0,0 +1,117 @@ +@page "/utilities" +@using Indotalent.Features.Utilities.BookingGroup.Components +@using Indotalent.Features.Utilities.BookingManager.Components +@using Indotalent.Features.Utilities.BookingResource.Components +@using Indotalent.Features.Utilities.BookingScheduler.Components +@using Indotalent.Features.Utilities.ProgramManager.Components +@using Indotalent.Features.Utilities.ProgramResource.Components +@using Indotalent.Features.Utilities.Todo.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "booking-group" }, + { 1, "booking-resource" }, + { 2, "booking-manager" }, + { 3, "scheduler" }, + { 4, "prog-resource" }, + { 5, "prog-manager" }, + { 6, "todo" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + _activeTabIndex = match.Value != null ? match.Key : 0; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/utilities?tab={tabName ?? "booking-group"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/_Imports.razor b/Features/_Imports.razor new file mode 100644 index 0000000..786c415 --- /dev/null +++ b/Features/_Imports.razor @@ -0,0 +1,3 @@ +@inherits Indotalent.ConfigFrontEnd.Common.BaseAppPage + + diff --git a/Indotalent.csproj b/Indotalent.csproj new file mode 100644 index 0000000..daadb81 --- /dev/null +++ b/Indotalent.csproj @@ -0,0 +1,55 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Indotalent.slnx b/Indotalent.slnx new file mode 100644 index 0000000..1b6748f --- /dev/null +++ b/Indotalent.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Infrastructure/Authentication/DI.cs b/Infrastructure/Authentication/DI.cs new file mode 100644 index 0000000..531bded --- /dev/null +++ b/Infrastructure/Authentication/DI.cs @@ -0,0 +1,88 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.Authentication.Keycloak; +using Indotalent.Infrastructure.Database; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace Indotalent.Infrastructure.Authentication; + +public static class DI +{ + public static IServiceCollection AddAuthenticationService(this IServiceCollection services, IConfiguration configuration) + { + //Identity + services.AddScoped(); + + services.AddHttpContextAccessor(); + services.AddCascadingAuthenticationState(); + + services.Configure(configuration.GetSection("JwtSettings")); + services.AddScoped(); + + var identitySettings = configuration.GetSection("IdentitySettings").Get() ?? new IdentitySettingsModel(); + + services.AddIdentity(options => + { + options.Password.RequireDigit = identitySettings.Password.RequireDigit; + options.Password.RequiredLength = identitySettings.Password.RequiredLength; + options.Password.RequireNonAlphanumeric = identitySettings.Password.RequireNonAlphanumeric; + options.Password.RequireUppercase = identitySettings.Password.RequireUppercase; + options.Password.RequireLowercase = identitySettings.Password.RequireLowercase; + options.SignIn.RequireConfirmedAccount = identitySettings.SignIn.RequireConfirmedAccount; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddClaimsPrincipalFactory(); + + services.AddScoped(); + + services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ApplicationScheme; + options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme; + }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + var jwtSettings = configuration.GetSection("JwtSettings").Get(); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings?.Issuer, + ValidAudience = jwtSettings?.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings?.Key ?? "PRAGMATIC_FIX_KEY_MIN_32_CHARS_LONG_2026")) + }; + }); + + services.ConfigureApplicationCookie(options => + { + options.LoginPath = identitySettings.Cookies.LoginPath; + options.LogoutPath = identitySettings.Cookies.LogoutPath; + options.AccessDeniedPath = identitySettings.Cookies.AccessDeniedPath; + options.Cookie.Name = identitySettings.Cookies.Name; + options.ExpireTimeSpan = TimeSpan.FromDays(identitySettings.Cookies.ExpireDays); + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + }); + + + //Firebase + + services.AddScoped(); + + //Keycloak + + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/Authentication/Firebase/FirebaseService.cs b/Infrastructure/Authentication/Firebase/FirebaseService.cs new file mode 100644 index 0000000..1f2f7ed --- /dev/null +++ b/Infrastructure/Authentication/Firebase/FirebaseService.cs @@ -0,0 +1,13 @@ +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Firebase; + +public class FirebaseService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public FirebaseSettingsModel GetDefaultAdmin() => _settings.SsoFirebase; +} diff --git a/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs b/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs new file mode 100644 index 0000000..914a313 --- /dev/null +++ b/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs @@ -0,0 +1,13 @@ +namespace Indotalent.Infrastructure.Authentication.Firebase; + +public class FirebaseSettingsModel +{ + public bool IsUsed { get; set; } + public bool OpenForPublic { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string AuthDomain { get; set; } = string.Empty; + public string ProjectId { get; set; } = string.Empty; + public string StorageBucket { get; set; } = string.Empty; + public string MessagingSenderId { get; set; } = string.Empty; + public string AppId { get; set; } = string.Empty; +} diff --git a/Infrastructure/Authentication/Identity/CookieSettings.cs b/Infrastructure/Authentication/Identity/CookieSettings.cs new file mode 100644 index 0000000..eff1356 --- /dev/null +++ b/Infrastructure/Authentication/Identity/CookieSettings.cs @@ -0,0 +1,10 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class CookieSettings +{ + public string Name { get; set; } = string.Empty; + public string LoginPath { get; set; } = string.Empty; + public string LogoutPath { get; set; } = string.Empty; + public string AccessDeniedPath { get; set; } = string.Empty; + public int ExpireDays { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs b/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs new file mode 100644 index 0000000..bff8ee8 --- /dev/null +++ b/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs @@ -0,0 +1,26 @@ +using Indotalent.Data.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class CustomClaimsPrincipalFactory : UserClaimsPrincipalFactory +{ + public CustomClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions optionsAccessor) + : base(userManager, roleManager, optionsAccessor) + { + } + + protected override async Task GenerateClaimsAsync(ApplicationUser user) + { + var identity = await base.GenerateClaimsAsync(user); + + identity.AddClaim(new Claim(ClaimTypes.GivenName, user.FullName ?? string.Empty)); + + return identity; + } +} diff --git a/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs b/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs new file mode 100644 index 0000000..3d7c761 --- /dev/null +++ b/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class DefaultAdminSettings +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; +} diff --git a/Infrastructure/Authentication/Identity/DefaultUserConfig.cs b/Infrastructure/Authentication/Identity/DefaultUserConfig.cs new file mode 100644 index 0000000..a1e4bac --- /dev/null +++ b/Infrastructure/Authentication/Identity/DefaultUserConfig.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Entities; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public static class DefaultUserConfig +{ + public static ApplicationUser GetAdminUser(DefaultAdminSettings settings) => new() + { + FullName = settings.FullName, + UserName = settings.Email, + Email = settings.Email, + EmailConfirmed = true, + CreatedAt = DateTime.Now, + IsActive = true + }; +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs b/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..0d26073 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,56 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using System.Security.Claims; + +internal sealed class IdentityRevalidatingAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TokenProvider _tokenProvider; + + public IdentityRevalidatingAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + IHttpContextAccessor httpContextAccessor, + TokenProvider tokenProvider) + : base(loggerFactory) + { + _scopeFactory = scopeFactory; + _httpContextAccessor = httpContextAccessor; + _tokenProvider = tokenProvider; + + var context = httpContextAccessor.HttpContext; + if (context != null) + { + var token = context.Request.Cookies["X-Auth-Token"]; + var refreshToken = context.Request.Cookies["X-Refresh-Token"]; + if (!string.IsNullOrEmpty(token)) + { + _tokenProvider.Token = token; + } + if (!string.IsNullOrEmpty(refreshToken)) + { + _tokenProvider.RefreshToken = refreshToken; + } + } + } + + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + await using var scopeAsync = _scopeFactory.CreateAsyncScope(); + var userManager = scopeAsync.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + return user != null; + } +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentityService.cs b/Infrastructure/Authentication/Identity/IdentityService.cs new file mode 100644 index 0000000..0944238 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentityService.cs @@ -0,0 +1,24 @@ +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Keycloak; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class IdentityService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public DefaultAdminSettings GetDefaultAdmin() => _settings.DefaultAdmin; + + public PasswordSettings GetPasswordPolicy() => _settings.Password; + + public CookieSettings GetCookieSettings() => _settings.Cookies; + + public SignInSettings GetSignInSettings() => _settings.SignIn; + + public FirebaseSettingsModel GetFirebaseSettings() => _settings.SsoFirebase; + + public KeycloakSettingsModel GetKeycloakSettings() => _settings.SsoKeycloak; +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs b/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs new file mode 100644 index 0000000..d895749 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs @@ -0,0 +1,14 @@ +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Keycloak; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class IdentitySettingsModel +{ + public PasswordSettings Password { get; set; } = new(); + public CookieSettings Cookies { get; set; } = new(); + public DefaultAdminSettings DefaultAdmin { get; set; } = new(); + public SignInSettings SignIn { get; set; } = new(); + public FirebaseSettingsModel SsoFirebase { get; set; } = new(); + public KeycloakSettingsModel SsoKeycloak { get; set; } = new(); +} diff --git a/Infrastructure/Authentication/Identity/JwtSettingsModel.cs b/Infrastructure/Authentication/Identity/JwtSettingsModel.cs new file mode 100644 index 0000000..01733cc --- /dev/null +++ b/Infrastructure/Authentication/Identity/JwtSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class JwtSettingsModel +{ + public string Key { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int DurationInMinutes { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/PasswordSettings.cs b/Infrastructure/Authentication/Identity/PasswordSettings.cs new file mode 100644 index 0000000..777cb97 --- /dev/null +++ b/Infrastructure/Authentication/Identity/PasswordSettings.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class PasswordSettings +{ + public bool RequireDigit { get; set; } + public int RequiredLength { get; set; } + public bool RequireNonAlphanumeric { get; set; } + public bool RequireUppercase { get; set; } + public bool RequireLowercase { get; set; } + public int RequiredUniqueChars { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/SignInSettings.cs b/Infrastructure/Authentication/Identity/SignInSettings.cs new file mode 100644 index 0000000..f9cad5d --- /dev/null +++ b/Infrastructure/Authentication/Identity/SignInSettings.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class SignInSettings +{ + public bool RequireConfirmedAccount { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/TokenProvider.cs b/Infrastructure/Authentication/Identity/TokenProvider.cs new file mode 100644 index 0000000..4796336 --- /dev/null +++ b/Infrastructure/Authentication/Identity/TokenProvider.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class TokenProvider +{ + public string? Token { get; set; } + public string? RefreshToken { get; set; } +} diff --git a/Infrastructure/Authentication/Keycloak/KeycloakService.cs b/Infrastructure/Authentication/Keycloak/KeycloakService.cs new file mode 100644 index 0000000..830f6e5 --- /dev/null +++ b/Infrastructure/Authentication/Keycloak/KeycloakService.cs @@ -0,0 +1,13 @@ +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Keycloak; + +public class KeycloakService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public KeycloakSettingsModel GetPasswordPolicy() => _settings.SsoKeycloak; +} diff --git a/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs b/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs new file mode 100644 index 0000000..d567a6a --- /dev/null +++ b/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Authentication.Keycloak; + +public class KeycloakSettingsModel +{ + public bool IsUsed { get; set; } + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string RequireHttpsMetadata { get; set; } = string.Empty; +} + diff --git a/Infrastructure/Authorization/Identity/ApplicationRoles.cs b/Infrastructure/Authorization/Identity/ApplicationRoles.cs new file mode 100644 index 0000000..ac1681f --- /dev/null +++ b/Infrastructure/Authorization/Identity/ApplicationRoles.cs @@ -0,0 +1,10 @@ +namespace Indotalent.Infrastructure.Authorization.Identity; + +public static class ApplicationRoles +{ + public const string Admin = "Admin"; + public const string Member = "Member"; + public const string Guest = "Guest"; + + public static IReadOnlyList AllRoles => new[] { Admin, Member, Guest }; +} \ No newline at end of file diff --git a/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs b/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs new file mode 100644 index 0000000..413700b --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs @@ -0,0 +1,94 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using System.Collections.Concurrent; + +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public class AutoNumberGeneratorService +{ + private readonly AppDbContext _context; + private static readonly ConcurrentDictionary _locks = new(); + + public AutoNumberGeneratorService(AppDbContext context) + { + _context = context; + } + + public async Task GenerateNextNumberAsync( + string entityName, + string prefixTemplate, + string? suffixTemplate = null, + int paddingLength = 4, + bool useYear = true, + bool useMonth = false, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + int? year = useYear ? now.Year : null; + int? month = useMonth ? now.Month : null; + + var lockKey = $"{entityName}_{year}_{month}"; + var semaphore = _locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(cancellationToken); + try + { + var sequence = await _context.AutoNumberSequence + .FirstOrDefaultAsync(ns => + ns.EntityName == entityName && + ns.Year == year && + ns.Month == month, + cancellationToken); + + if (sequence == null) + { + sequence = new AutoNumberSequence + { + EntityName = entityName, + Year = year, + Month = month, + CurrentSequence = 0, + PrefixTemplate = prefixTemplate, + SuffixTemplate = suffixTemplate, + PaddingLength = paddingLength + }; + _context.AutoNumberSequence.Add(sequence); + } + + sequence.CurrentSequence = (sequence.CurrentSequence ?? 0) + 1; + + var formattedNumber = BuildFormattedNumber(sequence, now); + + await _context.SaveChangesAsync(cancellationToken); + + return formattedNumber; + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) _locks.TryRemove(lockKey, out _); + } + } + + private string BuildFormattedNumber(AutoNumberSequence sequence, DateTime now) + { + string ProcessTemplate(string? template) + { + if (string.IsNullOrEmpty(template)) return ""; + return template + .Replace("{EntityName}", sequence.EntityName) + .Replace("{Year}", now.Year.ToString()) + .Replace("{Year2}", now.ToString("yy")) + .Replace("{Month}", now.Month.ToString("D2")); + } + + var prefix = ProcessTemplate(sequence.PrefixTemplate); + var suffix = ProcessTemplate(sequence.SuffixTemplate); + + var padding = sequence.PaddingLength ?? 4; + var paddedSequence = (sequence.CurrentSequence ?? 1).ToString($"D{padding}"); + + return $"{prefix}{paddedSequence}{suffix}"; + } +} \ No newline at end of file diff --git a/Infrastructure/AutoNumberGenerator/DI.cs b/Infrastructure/AutoNumberGenerator/DI.cs new file mode 100644 index 0000000..dbae5a0 --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/DI.cs @@ -0,0 +1,13 @@ + + +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public static class DI +{ + public static IServiceCollection AddAutoNumberGeneratorService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs b/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs new file mode 100644 index 0000000..41ca0f7 --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs @@ -0,0 +1,12 @@ +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public interface IAutoNumberGenerator +{ + string? EntityName { get; set; } + int? Year { get; set; } + int? Month { get; set; } + long? CurrentSequence { get; set; } + string? PrefixTemplate { get; set; } + string? SuffixTemplate { get; set; } + int? PaddingLength { get; set; } +} diff --git a/Infrastructure/BackgroundJob/BackgroundJobService.cs b/Infrastructure/BackgroundJob/BackgroundJobService.cs new file mode 100644 index 0000000..d1a1359 --- /dev/null +++ b/Infrastructure/BackgroundJob/BackgroundJobService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.BackgroundJob; + +public class BackgroundJobService(IOptions options) +{ + private readonly BackgroundJobSettingsModel _settings = options.Value; + + public BackgroundJobSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs b/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs new file mode 100644 index 0000000..fa3ec08 --- /dev/null +++ b/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs @@ -0,0 +1,10 @@ +using Indotalent.Infrastructure.BackgroundJob.Hangfire; +using Indotalent.Infrastructure.BackgroundJob.Quartz; + +namespace Indotalent.Infrastructure.BackgroundJob; + +public class BackgroundJobSettingsModel +{ + public HangfireSettingsModel Hangfire { get; set; } = new(); + public QuartzSettingsModel Quartz { get; set; } = new(); +} diff --git a/Infrastructure/BackgroundJob/DI.cs b/Infrastructure/BackgroundJob/DI.cs new file mode 100644 index 0000000..c7665e7 --- /dev/null +++ b/Infrastructure/BackgroundJob/DI.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.BackgroundJob; + +public static class DI +{ + public static IServiceCollection AddBackgroundJobService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs b/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs new file mode 100644 index 0000000..c43bcd5 --- /dev/null +++ b/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.BackgroundJob.Hangfire; + +public class HangfireSettingsModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public string DashboardPath { get; set; } = "/hangfire"; +} diff --git a/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs b/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs new file mode 100644 index 0000000..2b9d092 --- /dev/null +++ b/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.BackgroundJob.Quartz; + +public class QuartzSettingsModel +{ + public bool IsUsed { get; set; } + public string TablePrefix { get; set; } = "QRTZ_"; +} diff --git a/Infrastructure/DI.cs b/Infrastructure/DI.cs new file mode 100644 index 0000000..1201b70 --- /dev/null +++ b/Infrastructure/DI.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Authentication; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Infrastructure.BackgroundJob; +using Indotalent.Infrastructure.Database; +using Indotalent.Infrastructure.Email; +using Indotalent.Infrastructure.File; +using Indotalent.Infrastructure.Logging; +using Indotalent.Infrastructure.OData; + +namespace Indotalent.Infrastructure; + +public static class DI +{ + public static IServiceCollection AddInfrastructureDI(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("DatabaseSettings")); + services.Configure(configuration.GetSection("BackgroundJobSettings")); + services.Configure(configuration.GetSection("EmailSettings")); + services.Configure(configuration.GetSection("FileStorageSettings")); + services.Configure(configuration.GetSection("LoggerSettings")); + services.Configure(configuration.GetSection("IdentitySettings")); + + services.AddLoggingService(); + + services.AddDatabaseService(configuration); + + services.AddBackgroundJobService(); + + services.AddEmailService(configuration); + + services.AddFileService(); + + services.AddAuthenticationService(configuration); + + services.AddAutoNumberGeneratorService(); + + services.AddODataService(); + + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/AppDbContext.cs b/Infrastructure/Database/AppDbContext.cs new file mode 100644 index 0000000..ad5b983 --- /dev/null +++ b/Infrastructure/Database/AppDbContext.cs @@ -0,0 +1,200 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Data.Abstracts; +using Indotalent.Data.Entities; +using Indotalent.Data.Interfaces; +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Shared.Consts; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public class AppDbContext : IdentityDbContext +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ICurrentUserService _currentUserService; + + public AppDbContext( + DbContextOptions options, + IServiceScopeFactory scopeFactory, + ICurrentUserService currentUserService) : base(options) + { + _scopeFactory = scopeFactory; + _currentUserService = currentUserService; + } + + public DbSet AutoNumberSequence { get; set; } = default!; + public DbSet Currency { get; set; } = default!; + public DbSet Company { get; set; } = default!; + public DbSet Tax { get; set; } = default!; + public DbSet PaymentMethod { get; set; } = default!; + public DbSet Booking { get; set; } = default!; + public DbSet BookingGroup { get; set; } = default!; + public DbSet BookingResource { get; set; } = default!; + public DbSet Bill { get; set; } = default!; + public DbSet PaymentDisburse { get; set; } = default!; + public DbSet Invoice { get; set; } = default!; + public DbSet PaymentReceive { get; set; } = default!; + public DbSet ProductGroup { get; set; } = default!; + public DbSet ProgramManager { get; set; } = default!; + public DbSet ProgramManagerResource { get; set; } = default!; + public DbSet PurchaseOrder { get; set; } = default!; + public DbSet PurchaseOrderItem { get; set; } = default!; + public DbSet Customer { get; set; } = default!; + public DbSet CustomerCategory { get; set; } = default!; + public DbSet CustomerContact { get; set; } = default!; + public DbSet CustomerGroup { get; set; } = default!; + public DbSet Product { get; set; } = default!; + public DbSet SalesOrder { get; set; } = default!; + public DbSet SalesOrderItem { get; set; } = default!; + public DbSet Todo { get; set; } = default!; + public DbSet TodoItem { get; set; } = default!; + public DbSet UnitMeasure { get; set; } = default!; + public DbSet Vendor { get; set; } = default!; + public DbSet VendorCategory { get; set; } = default!; + public DbSet VendorContact { get; set; } = default!; + public DbSet VendorGroup { get; set; } = default!; + public DbSet Warehouse { get; set; } = default!; + public DbSet InventoryTransaction { get; set; } = default!; + + public override int SaveChanges() + { + ApplySoftDelete(); + ApplyAudit(_currentUserService.UserId ?? string.Empty); + return base.SaveChanges(); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + ChangeTracker.DetectChanges(); + ApplySoftDelete(); + ApplyAudit(_currentUserService.UserId ?? string.Empty); + await ApplyAutoNumber(cancellationToken); + return await base.SaveChangesAsync(cancellationToken); + } + + private void ApplySoftDelete() + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Deleted); + + foreach (var entry in entries) + { + entry.State = EntityState.Modified; + entry.Entity.IsDeleted = true; + } + } + + private void ApplyAudit(string userId) + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified) + .ToList(); + + foreach (var entry in entries) + { + var now = DateTimeOffset.UtcNow; + var auditEntity = entry.Entity; + + if (entry.State == EntityState.Added) + { + auditEntity.CreatedAt = now; + auditEntity.CreatedBy = userId; + } + else + { + entry.Property(nameof(IHasAudit.CreatedAt)).IsModified = false; + entry.Property(nameof(IHasAudit.CreatedBy)).IsModified = false; + } + + auditEntity.UpdatedAt = now; + auditEntity.UpdatedBy = userId; + } + } + + private async Task ApplyAutoNumber(CancellationToken ct) + { + var entries = ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Added && string.IsNullOrEmpty(e.Entity.AutoNumber)) + .ToList(); + + if (!entries.Any()) return; + + using var scope = _scopeFactory.CreateScope(); + var generator = scope.ServiceProvider.GetRequiredService(); + + foreach (var entry in entries) + { + var entityName = entry.Entity.GetType().Name; + var shortName = entityName.ToShortNameConsonant(3); + + entry.Entity.AutoNumber = await generator.GenerateNextNumberAsync( + entityName: entityName, + prefixTemplate: $"{shortName}/{{Year}}/", + paddingLength: 4, + cancellationToken: ct + ); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + if (Database.ProviderName?.Contains("MySql") == true) + { + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (entity.GetTableName()!.StartsWith("AspNet")) + { + foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(string))) + { + property.SetMaxLength(191); + } + } + } + } + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + var type = entityType.ClrType; + + if (typeof(BaseEntity).IsAssignableFrom(type)) + { + modelBuilder.Entity(type) + .Property("Id") + .HasMaxLength(GlobalConsts.StringLengthId) + .IsFixedLength(); + } + + if (typeof(IHasAudit).IsAssignableFrom(type)) + { + modelBuilder.Entity(type) + .Property(nameof(IHasAudit.CreatedBy)) + .HasMaxLength(GlobalConsts.StringLengthShort); + + modelBuilder.Entity(type) + .Property(nameof(IHasAudit.UpdatedBy)) + .HasMaxLength(GlobalConsts.StringLengthShort); + } + + if (typeof(IHasIsDeleted).IsAssignableFrom(type)) + { + modelBuilder.Entity(type).HasQueryFilter(GetNotDeletedOnlyFilter(type)); + } + } + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } + + private static System.Linq.Expressions.LambdaExpression GetNotDeletedOnlyFilter(Type type) + { + var parameter = System.Linq.Expressions.Expression.Parameter(type, "e"); + var property = System.Linq.Expressions.Expression.Property(parameter, nameof(IHasIsDeleted.IsDeleted)); + var falseConstant = System.Linq.Expressions.Expression.Constant(false); + var comparison = System.Linq.Expressions.Expression.Equal(property, falseConstant); + return System.Linq.Expressions.Expression.Lambda(comparison, parameter); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/AppDbContextExtensions.cs b/Infrastructure/Database/AppDbContextExtensions.cs new file mode 100644 index 0000000..07affa4 --- /dev/null +++ b/Infrastructure/Database/AppDbContextExtensions.cs @@ -0,0 +1,24 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Utils; + +namespace Indotalent.Infrastructure.Database; + +public static class AppDbContextExtensions +{ + public static void CalculateInvenTrans(this AppDbContext context, InventoryTransaction transaction) + { + InventoryTransactionHelper.CalculateInvenTrans(context, transaction); + } + public static double GetStock(this AppDbContext context, string? warehouseId, string? productId, string? currentId = null) + { + return InventoryTransactionHelper.GetStock(context, warehouseId, productId, currentId); + } + public static void RecalculatePurchaseOrder(this AppDbContext context, string purchaseOrderId) + { + PurchaseOrderHelper.Recalculate(context, purchaseOrderId); + } + public static void RecalculateSalesOrder(this AppDbContext context, string salesOrderId) + { + SalesOrderHelper.Recalculate(context, salesOrderId); + } +} diff --git a/Infrastructure/Database/DI.cs b/Infrastructure/Database/DI.cs new file mode 100644 index 0000000..4170a09 --- /dev/null +++ b/Infrastructure/Database/DI.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public static class DI +{ + public static IServiceCollection AddDatabaseService(this IServiceCollection services, IConfiguration configuration) + { + var dbSettings = configuration.GetSection("DatabaseSettings").Get(); + + if (dbSettings?.MsSQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseSqlServer( + dbSettings.MsSQL.ConnectionString, + sqlOptions => + { + sqlOptions.CommandTimeout(dbSettings.MsSQL.TimeoutInSeconds); + } + ) + ); + } + else if (dbSettings?.PostgreSQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseNpgsql( + dbSettings.PostgreSQL.ConnectionString, + npgsqlOptions => + { + npgsqlOptions.CommandTimeout(dbSettings.PostgreSQL.TimeoutInSeconds); + } + ) + ); + } + else if (dbSettings?.MySQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseMySQL( + dbSettings.MySQL.ConnectionString, + mySqlOptions => + { + mySqlOptions.CommandTimeout(dbSettings.MySQL.TimeoutInSeconds); + mySqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); + } + ) + ); + } + + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/DatabaseProviderModel.cs b/Infrastructure/Database/DatabaseProviderModel.cs new file mode 100644 index 0000000..7d707c9 --- /dev/null +++ b/Infrastructure/Database/DatabaseProviderModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Database; + +public class DatabaseProviderModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public int TimeoutInSeconds { get; set; } +} diff --git a/Infrastructure/Database/DatabaseSeeder.cs b/Infrastructure/Database/DatabaseSeeder.cs new file mode 100644 index 0000000..4329617 --- /dev/null +++ b/Infrastructure/Database/DatabaseSeeder.cs @@ -0,0 +1,138 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.Authorization.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public static class DatabaseSeeder +{ + public static async Task SeedAsync(IServiceProvider serviceProvider) + { + var roleManager = serviceProvider.GetRequiredService>(); + var userManager = serviceProvider.GetRequiredService>(); + var configuration = serviceProvider.GetRequiredService(); + var context = serviceProvider.GetRequiredService(); + + await SeedIdentityAsync(roleManager, userManager, configuration); + await SeedCurrenciesAsync(context); + await SeedCompanyAsync(context); + await SeedTaxAsync(context); + await SeedSystemWarehouseAsync(context); + + await DatabaseSeederDemo.SeedAsync(serviceProvider); + } + + private static async Task SeedIdentityAsync(RoleManager roleManager, UserManager userManager, IConfiguration configuration) + { + var adminSettings = configuration.GetSection("IdentitySettings:DefaultAdmin").Get(); + if (adminSettings == null) return; + + foreach (var roleName in ApplicationRoles.AllRoles) + { + if (!await roleManager.RoleExistsAsync(roleName)) + { + await roleManager.CreateAsync(new IdentityRole(roleName)); + } + } + + var existingUser = await userManager.FindByEmailAsync(adminSettings.Email); + if (existingUser == null) + { + var defaultAdmin = DefaultUserConfig.GetAdminUser(adminSettings); + var createAdmin = await userManager.CreateAsync(defaultAdmin, adminSettings.Password); + + if (createAdmin.Succeeded) + { + await userManager.AddToRolesAsync(defaultAdmin, ApplicationRoles.AllRoles); + } + } + } + + private static async Task SeedCurrenciesAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var currencies = new List + { + new() { Name = "US Dollar", Code = "USD", Symbol = "$", Description = "United States official currency", CountryOwner = "United States" }, + new() { Name = "Euro", Code = "EUR", Symbol = "€", Description = "Eurozone member states currency", CountryOwner = "European Union" }, + new() { Name = "British Pound", Code = "GBP", Symbol = "£", Description = "Official currency of the UK", CountryOwner = "United Kingdom" }, + new() { Name = "Indonesian Rupiah", Code = "IDR", Symbol = "Rp", Description = "Official currency of Indonesia", CountryOwner = "Indonesia" } + }; + + await context.Set().AddRangeAsync(currencies); + await context.SaveChangesAsync(); + } + + private static async Task SeedCompanyAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var usdCurrency = await context.Set().FirstOrDefaultAsync(x => x.Code == "USD"); + if (usdCurrency == null) return; + + var companies = new List + { + new() + { + Name = "Acme Global Solutions Inc.", + Description = "A leading provider of enterprise-grade cloud software solutions.", + IsDefault = true, + CurrencyId = usdCurrency.Id, + TaxIdentification = "EIN 12-3456789", + BusinessLicense = "LC-987654321", + StreetAddress = "One World Trade Center, Suite 85", + City = "New York", + StateProvince = "NY", + ZipCode = "10007", + Phone = "+1 212 555 0198", + Email = "hr@acmeglobal.com", + SocialMediaLinkedIn = "linkedin.com/company/acme-global", + CompanyLogo = "/images/logos/acme-logo.png", + OtherInformation1 = "Corporate Headquarters", + OtherInformation2 = "Technology & SaaS", + OtherInformation3 = "Fortune 500 Candidate" + } + }; + + await context.Set().AddRangeAsync(companies); + await context.SaveChangesAsync(); + } + + private static async Task SeedTaxAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var taxes = new List + { + new Tax { Code = "NOTAX", Name = "NOTAX", PercentageValue = 0 }, + new Tax { Code = "T10", Name = "T10", PercentageValue = 10 }, + new Tax { Code = "T15", Name = "T15", PercentageValue = 15 }, + new Tax { Code = "T20", Name = "T20", PercentageValue = 20 }, + }; + + await context.Set().AddRangeAsync(taxes); + await context.SaveChangesAsync(); + } + + private static async Task SeedSystemWarehouseAsync(AppDbContext context) + { + if (await context.Set().AnyAsync(x => x.SystemWarehouse == true)) return; + + var warehouses = new List + { + new Warehouse { Name = "Customer", SystemWarehouse = true }, + new Warehouse { Name = "Vendor", SystemWarehouse = true }, + new Warehouse { Name = "Transfer", SystemWarehouse = true }, + new Warehouse { Name = "Adjustment", SystemWarehouse = true }, + new Warehouse { Name = "StockCount", SystemWarehouse = true }, + new Warehouse { Name = "Scrapping", SystemWarehouse = true } + }; + + await context.Set().AddRangeAsync(warehouses); + await context.SaveChangesAsync(); + } + +} \ No newline at end of file diff --git a/Infrastructure/Database/DatabaseSeederDemo.cs b/Infrastructure/Database/DatabaseSeederDemo.cs new file mode 100644 index 0000000..d95d44b --- /dev/null +++ b/Infrastructure/Database/DatabaseSeederDemo.cs @@ -0,0 +1,59 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database.Demo; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Indotalent.Infrastructure.Database; + +public static class DatabaseSeederDemo +{ + public static async Task SeedAsync(IServiceProvider serviceProvider) + { + var roleManager = serviceProvider.GetRequiredService>(); + var userManager = serviceProvider.GetRequiredService>(); + var configuration = serviceProvider.GetRequiredService(); + var context = serviceProvider.GetRequiredService(); + + // 1. Master Data (Must be first) + await UserSeeder.GenerateDataAsync(userManager, context, configuration); + await UnitMeasureSeeder.GenerateDataAsync(context); + await WarehouseSeeder.GenerateDataAsync(context); + await PaymentMethodSeeder.GenerateDataAsync(context); + + // 2. Customer & Vendor Related + await CustomerCategorySeeder.GenerateDataAsync(context); + await CustomerGroupSeeder.GenerateDataAsync(context); + await CustomerSeeder.GenerateDataAsync(context); + await CustomerContactSeeder.GenerateDataAsync(context); + + await VendorCategorySeeder.GenerateDataAsync(context); + await VendorGroupSeeder.GenerateDataAsync(context); + await VendorSeeder.GenerateDataAsync(context); + await VendorContactSeeder.GenerateDataAsync(context); + + // 3. Product Related + await ProductGroupSeeder.GenerateDataAsync(context); + await ProductSeeder.GenerateDataAsync(context); + + // 4. Sales Related + await SalesOrderSeeder.GenerateDataAsync(context); + + // 5. Purchase Related + await PurchaseOrderSeeder.GenerateDataAsync(context); + + // 6. Inventory Operations + + // 7. Finance & Project Related + await InvoiceSeeder.GenerateDataAsync(context); + await PaymentReceiveSeeder.GenerateDataAsync(context); + await BillSeeder.GenerateDataAsync(context); + await PaymentDisburseSeeder.GenerateDataAsync(context); + + await BookingGroupSeeder.GenerateDataAsync(context); + await BookingResourceSeeder.GenerateDataAsync(context); + await BookingSeeder.GenerateDataAsync(context); + await ProgramManagerResourceSeeder.GenerateDataAsync(context); + await ProgramManagerSeeder.GenerateDataAsync(context); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/DatabaseService.cs b/Infrastructure/Database/DatabaseService.cs new file mode 100644 index 0000000..287bdab --- /dev/null +++ b/Infrastructure/Database/DatabaseService.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Database; + +public class DatabaseService(IOptions options) +{ + private readonly DatabaseSettingsModel _settings = options.Value; + + public DatabaseSettingsModel GetConfiguration() => _settings; + + public DatabaseProviderModel GetActiveProvider() + { + if (_settings.MsSQL.IsUsed) return _settings.MsSQL; + if (_settings.MySQL.IsUsed) return _settings.MySQL; + if (_settings.PostgreSQL.IsUsed) return _settings.PostgreSQL; + return new DatabaseProviderModel(); + } +} diff --git a/Infrastructure/Database/DatabaseSettingsModel.cs b/Infrastructure/Database/DatabaseSettingsModel.cs new file mode 100644 index 0000000..7ee7ad4 --- /dev/null +++ b/Infrastructure/Database/DatabaseSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Database; + +public class DatabaseSettingsModel +{ + public DatabaseProviderModel MsSQL { get; set; } = new(); + public DatabaseProviderModel MySQL { get; set; } = new(); + public DatabaseProviderModel PostgreSQL { get; set; } = new(); +} diff --git a/Infrastructure/Database/Demo/BillSeeder.cs b/Infrastructure/Database/Demo/BillSeeder.cs new file mode 100644 index 0000000..f84e872 --- /dev/null +++ b/Infrastructure/Database/Demo/BillSeeder.cs @@ -0,0 +1,104 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class BillSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Bill.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Bill); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedPurchaseOrders = await context.PurchaseOrder + .Where(po => po.OrderStatus == PurchaseOrderStatus.Confirmed) + .Select(po => po.Id) + .ToListAsync(); + + if (!confirmedPurchaseOrders.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] billDates = GetRandomDays(date.Year, date.Month, 5); + + foreach (var billDate in billDates) + { + if (confirmedPurchaseOrders.Count == 0) break; + + var status = GetRandomStatus(random); + var purchaseOrderId = GetRandomAndRemove(confirmedPurchaseOrders, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var bill = new Bill + { + AutoNumber = autoNo, + BillDate = billDate, + BillStatus = status, + Description = $"Bill for {billDate:MMMM yyyy}", + PurchaseOrderId = purchaseOrderId + }; + + await context.Bill.AddAsync(bill); + } + } + + await context.SaveChangesAsync(); + } + + private static BillStatus GetRandomStatus(Random random) + { + var statuses = new[] + { + BillStatus.Draft, + BillStatus.Cancelled, + BillStatus.Confirmed + }; + var weights = new[] { 1, 1, 4 }; + + int totalWeight = weights.Sum(); + int randomNumber = random.Next(0, totalWeight); + + for (int i = 0; i < statuses.Length; i++) + { + if (randomNumber < weights[i]) return statuses[i]; + randomNumber -= weights[i]; + } + + return BillStatus.Confirmed; + } + + private static string GetRandomAndRemove(List list, Random random) + { + int index = random.Next(list.Count); + string value = list[index]; + list.RemoveAt(index); + return value; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/BookingGroupSeeder.cs b/Infrastructure/Database/Demo/BookingGroupSeeder.cs new file mode 100644 index 0000000..ae8fc79 --- /dev/null +++ b/Infrastructure/Database/Demo/BookingGroupSeeder.cs @@ -0,0 +1,26 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class BookingGroupSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.BookingGroup.AnyAsync()) return; + + var bookingGroups = new List + { + new BookingGroup { Name = "Vehicle" }, + new BookingGroup { Name = "Room" }, + new BookingGroup { Name = "Electronic" } + }; + + foreach (var bookingGroup in bookingGroups) + { + await context.BookingGroup.AddAsync(bookingGroup); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/BookingResourceSeeder.cs b/Infrastructure/Database/Demo/BookingResourceSeeder.cs new file mode 100644 index 0000000..f22d154 --- /dev/null +++ b/Infrastructure/Database/Demo/BookingResourceSeeder.cs @@ -0,0 +1,71 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class BookingResourceSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.BookingResource.AnyAsync()) return; + + var vehicleGroup = await context.BookingGroup.Where(x => x.Name == "Vehicle").SingleOrDefaultAsync(); + if (vehicleGroup != null) + { + var vehicleResources = new List + { + new BookingResource { Name = "Audi 01", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "Audi 02", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "Audi 03", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "BMW 01", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "BMW 02", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "BMW 03", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "Lexus 01", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "Lexus 02", BookingGroupId = vehicleGroup.Id }, + new BookingResource { Name = "Lexus 03", BookingGroupId = vehicleGroup.Id } + }; + + await context.BookingResource.AddRangeAsync(vehicleResources); + } + + var roomGroup = await context.BookingGroup.Where(x => x.Name == "Room").SingleOrDefaultAsync(); + if (roomGroup != null) + { + var roomResources = new List + { + new BookingResource { Name = "Room One", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Room Two", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Room Three", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Conference One", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Conference Two", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Conference Three", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Studio One", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Studio Two", BookingGroupId = roomGroup.Id }, + new BookingResource { Name = "Studio Three", BookingGroupId = roomGroup.Id } + }; + + await context.BookingResource.AddRangeAsync(roomResources); + } + + var electronicGroup = await context.BookingGroup.Where(x => x.Name == "Electronic").SingleOrDefaultAsync(); + if (electronicGroup != null) + { + var electronicResources = new List + { + new BookingResource { Name = "Epson Projector", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Sony Projector", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Bose Speaker", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "JBL Speaker", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Microsoft Webcam", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Logitech Webcam", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Google Chromecast", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Apple TV", BookingGroupId = electronicGroup.Id }, + new BookingResource { Name = "Samsung Monitor 49", BookingGroupId = electronicGroup.Id } + }; + + await context.BookingResource.AddRangeAsync(electronicResources); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/BookingSeeder.cs b/Infrastructure/Database/Demo/BookingSeeder.cs new file mode 100644 index 0000000..dfb4647 --- /dev/null +++ b/Infrastructure/Database/Demo/BookingSeeder.cs @@ -0,0 +1,85 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class BookingSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Booking.AnyAsync()) return; + + var random = new Random(); + var bookingStatusValues = Enum.GetValues(typeof(BookingStatus)).Cast().ToList(); + var entityName = nameof(Booking); + + var dateEnd = DateTime.Now.AddMonths(6); + var dateStart = dateEnd.AddMonths(-6); + + var bookingResources = await context.BookingResource + .Select(x => x.Id) + .ToListAsync(); + + if (!bookingResources.Any()) return; + + var dummyLocations = new List + { + "123 Main St, New York", + "456 Elm St, Los Angeles", + "789 Pine St, Chicago", + "101 Maple St, Houston", + "202 Oak St, Phoenix", + "303 Birch St, Philadelphia", + "404 Cedar St, San Antonio", + "505 Walnut St, San Diego", + "606 Aspen St, Dallas", + "707 Spruce St, San Jose" + }; + + for (DateTime date = dateStart; date < dateEnd; date = date.AddMonths(1)) + { + DateTime[] transactionDates = GenerateRandomDays(date.Year, date.Month, 12); + foreach (DateTime transDate in transactionDates) + { + TimeSpan randomTime = TimeSpan.FromHours(random.Next(8, 12)); + DateTime startTime = transDate.Date.Add(randomTime); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var booking = new Booking + { + Subject = autoNo, + AutoNumber = autoNo, + StartTime = startTime, + EndTime = startTime.AddHours(random.Next(2, 12)), + BookingResourceId = GetRandomValue(bookingResources, random), + Status = bookingStatusValues[random.Next(bookingStatusValues.Count)], + Location = GetRandomValue(dummyLocations, random) + }; + + await context.Booking.AddAsync(booking); + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime[] GenerateRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + return Enumerable.Range(1, count) + .Select(_ => new DateTime(year, month, random.Next(1, daysInMonthCount + 1))) + .ToArray(); + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/CustomerCategorySeeder.cs b/Infrastructure/Database/Demo/CustomerCategorySeeder.cs new file mode 100644 index 0000000..14f17bb --- /dev/null +++ b/Infrastructure/Database/Demo/CustomerCategorySeeder.cs @@ -0,0 +1,28 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class CustomerCategorySeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.CustomerCategory.AnyAsync()) return; + + var customerCategories = new List + { + new CustomerCategory { Name = "Enterprise" }, + new CustomerCategory { Name = "Medium" }, + new CustomerCategory { Name = "Small" }, + new CustomerCategory { Name = "Startup" }, + new CustomerCategory { Name = "Micro" } + }; + + foreach (var category in customerCategories) + { + await context.CustomerCategory.AddAsync(category); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/CustomerContactSeeder.cs b/Infrastructure/Database/Demo/CustomerContactSeeder.cs new file mode 100644 index 0000000..c7d0725 --- /dev/null +++ b/Infrastructure/Database/Demo/CustomerContactSeeder.cs @@ -0,0 +1,80 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class CustomerContactSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.CustomerContact.AnyAsync()) return; + + var firstNames = new string[] + { + "Adam", "Sarah", "Michael", "Emily", "David", "Jessica", + "Kevin", "Samantha", "Jason", "Olivia", "Matthew", "Ashley", + "Christopher", "Jennifer", "Nicholas", "Amanda", "Alexander", + "Stephanie", "Jonathan", "Lauren" + }; + + var lastNames = new string[] + { + "Johnson", "Williams", "Brown", "Jones", "Miller", "Davis", + "Garcia", "Rodriguez", "Wilson", "Martinez", "Anderson", "Taylor", + "Thomas", "Hernandez", "Moore", "Martin", "Jackson", "Thompson", + "White", "Lopez" + }; + + var jobTitles = new string[] + { + "Chief Executive Officer", "Data Scientist", "Product Manager", "Business Development Executive", + "IT Consultant", "Social Media Specialist", "Research Analyst", "Content Writer", + "Operations Manager", "Financial Planner", "Software Developer", "Customer Success Manager", + "Marketing Coordinator", "Quality Assurance Tester", "HR Specialist", "Event Coordinator", + "Account Executive", "Network Administrator", "Sales Manager", "Legal Assistant" + }; + + var customerIds = await context.Customer.Select(x => x.Id).ToListAsync(); + var random = new Random(); + var entityName = nameof(CustomerContact); + + foreach (var customerId in customerIds) + { + for (int i = 0; i < 3; i++) + { + var firstName = GetRandomString(firstNames, random); + var lastName = GetRandomString(lastNames, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var contact = new CustomerContact + { + Name = $"{firstName} {lastName}", + AutoNumber = autoNo, + CustomerId = customerId, + JobTitle = GetRandomString(jobTitles, random), + EmailAddress = $"{firstName.ToLower()}.{lastName.ToLower()}@gmail.com", + PhoneNumber = GenerateRandomPhoneNumber(random) + }; + + await context.CustomerContact.AddAsync(contact); + } + } + + await context.SaveChangesAsync(); + } + + private static string GetRandomString(string[] array, Random random) + { + return array[random.Next(array.Length)]; + } + + private static string GenerateRandomPhoneNumber(Random random) + { + return $"+1-{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}"; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/CustomerGroupSeeder.cs b/Infrastructure/Database/Demo/CustomerGroupSeeder.cs new file mode 100644 index 0000000..3700588 --- /dev/null +++ b/Infrastructure/Database/Demo/CustomerGroupSeeder.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class CustomerGroupSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.CustomerGroup.AnyAsync()) return; + + var customerGroups = new List + { + new CustomerGroup { Name = "Corporate" }, + new CustomerGroup { Name = "Government" }, + new CustomerGroup { Name = "Foundation" }, + new CustomerGroup { Name = "Military" }, + new CustomerGroup { Name = "Education" }, + new CustomerGroup { Name = "Hospitality" } + }; + + foreach (var group in customerGroups) + { + await context.CustomerGroup.AddAsync(group); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/CustomerSeeder.cs b/Infrastructure/Database/Demo/CustomerSeeder.cs new file mode 100644 index 0000000..81225a8 --- /dev/null +++ b/Infrastructure/Database/Demo/CustomerSeeder.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class CustomerSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Customer.AnyAsync()) return; + + var groups = await context.CustomerGroup.Select(x => x.Id).ToArrayAsync(); + var categories = await context.CustomerCategory.Select(x => x.Id).ToArrayAsync(); + + var cities = new string[] { "New York", "Los Angeles", "San Francisco", "Chicago" }; + var streets = new string[] { "Main St", "Broadway", "Market St", "Elm St" }; + var states = new string[] { "NY", "CA", "IL", "TX" }; + var zipCodes = new string[] { "10001", "90001", "94101", "60601" }; + var phoneNumbers = new string[] { "555-1234", "555-5678", "555-8765", "555-4321" }; + var emailDomains = new string[] { "example.com", "demo.com", "test.com", "sample.com" }; + + var random = new Random(); + var entityName = nameof(Customer); + + var customers = new List + { + new Customer { Name = "Citadel LLC" }, + new Customer { Name = "Ironclad LLC" }, + new Customer { Name = "Armada LLC" }, + new Customer { Name = "Shield LLC" }, + new Customer { Name = "Alpha LLC" }, + new Customer { Name = "Capitol LLC" }, + new Customer { Name = "Federal LLC" }, + new Customer { Name = "Statewide LLC" }, + new Customer { Name = "Harmony LLC" }, + new Customer { Name = "Hope LLC" }, + new Customer { Name = "Unity LLC" }, + new Customer { Name = "Prosperity LLC" }, + new Customer { Name = "Global LLC" }, + new Customer { Name = "Sunset LLC" }, + new Customer { Name = "Luxe LLC" }, + new Customer { Name = "Serenity LLC" }, + new Customer { Name = "Oasis LLC" }, + new Customer { Name = "Grandeur LLC" }, + new Customer { Name = "Bright LLC" }, + new Customer { Name = "Stellar LLC" } + }; + + foreach (var customer in customers) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + customer.AutoNumber = autoNo; + customer.CustomerGroupId = GetRandomValue(groups, random); + customer.CustomerCategoryId = GetRandomValue(categories, random); + customer.City = GetRandomString(cities, random); + customer.Street = GetRandomString(streets, random); + customer.State = GetRandomString(states, random); + customer.ZipCode = GetRandomString(zipCodes, random); + customer.PhoneNumber = GetRandomString(phoneNumbers, random); + customer.EmailAddress = $"{customer.Name?.Split(' ')[0].ToLower()}@{GetRandomString(emailDomains, random)}"; + + await context.Customer.AddAsync(customer); + } + + await context.SaveChangesAsync(); + } + + private static T GetRandomValue(T[] array, Random random) + { + return array[random.Next(array.Length)]; + } + + private static string GetRandomString(string[] array, Random random) + { + return array[random.Next(array.Length)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/InvoiceSeeder.cs b/Infrastructure/Database/Demo/InvoiceSeeder.cs new file mode 100644 index 0000000..b3a845b --- /dev/null +++ b/Infrastructure/Database/Demo/InvoiceSeeder.cs @@ -0,0 +1,99 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class InvoiceSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Invoice.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Invoice); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedSalesOrders = await context.SalesOrder + .Where(so => so.OrderStatus == SalesOrderStatus.Confirmed) + .Select(so => so.Id) + .ToListAsync(); + + if (!confirmedSalesOrders.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] invoiceDates = GetRandomDays(date.Year, date.Month, 5); + + foreach (var invoiceDate in invoiceDates) + { + if (confirmedSalesOrders.Count == 0) break; + + var status = GetRandomStatus(random); + var salesOrderId = GetRandomAndRemove(confirmedSalesOrders, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var invoice = new Invoice + { + AutoNumber = autoNo, + InvoiceDate = invoiceDate, + InvoiceStatus = status, + Description = $"Invoice for {invoiceDate:MMMM yyyy}", + SalesOrderId = salesOrderId + }; + + await context.Invoice.AddAsync(invoice); + } + } + + await context.SaveChangesAsync(); + } + + private static InvoiceStatus GetRandomStatus(Random random) + { + var statuses = new[] { InvoiceStatus.Draft, InvoiceStatus.Cancelled, InvoiceStatus.Confirmed }; + var weights = new[] { 1, 1, 4 }; + + int totalWeight = weights.Sum(); + int randomNumber = random.Next(0, totalWeight); + + for (int i = 0; i < statuses.Length; i++) + { + if (randomNumber < weights[i]) return statuses[i]; + randomNumber -= weights[i]; + } + + return InvoiceStatus.Confirmed; + } + + private static string GetRandomAndRemove(List list, Random random) + { + int index = random.Next(list.Count); + string value = list[index]; + list.RemoveAt(index); + return value; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/PaymentDisburseSeeder.cs b/Infrastructure/Database/Demo/PaymentDisburseSeeder.cs new file mode 100644 index 0000000..7184ca9 --- /dev/null +++ b/Infrastructure/Database/Demo/PaymentDisburseSeeder.cs @@ -0,0 +1,117 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PaymentDisburseSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PaymentDisburse.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(PaymentDisburse); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedBills = await context.Bill + .Include(b => b.PurchaseOrder) + .Where(b => b.BillStatus == BillStatus.Confirmed) + .ToListAsync(); + + var paymentMethods = await context.PaymentMethod + .Select(pm => pm.Id) + .ToListAsync(); + + if (!paymentMethods.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] paymentDates = GetRandomDays(date.Year, date.Month, 4); + + foreach (var paymentDate in paymentDates) + { + if (confirmedBills.Count == 0) break; + + var status = GetRandomStatus(random); + var bill = GetRandomAndRemove(confirmedBills, random); + var paymentMethodId = GetRandomValue(paymentMethods, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var paymentDisburse = new PaymentDisburse + { + AutoNumber = autoNo, + Description = $"Payment Disbursed on {paymentDate:MMMM yyyy}", + PaymentDate = paymentDate, + PaymentMethodId = paymentMethodId, + PaymentAmount = bill.PurchaseOrder?.AfterTaxAmount, + Status = status, + BillId = bill.Id + }; + + await context.PaymentDisburse.AddAsync(paymentDisburse); + } + } + + await context.SaveChangesAsync(); + } + + private static PaymentDisburseStatus GetRandomStatus(Random random) + { + var statuses = new[] + { + PaymentDisburseStatus.Draft, + PaymentDisburseStatus.Cancelled, + PaymentDisburseStatus.Confirmed, + PaymentDisburseStatus.Archived + }; + var weights = new[] { 1, 1, 4, 1 }; + + int totalWeight = weights.Sum(); + int randomNumber = random.Next(0, totalWeight); + + for (int i = 0; i < statuses.Length; i++) + { + if (randomNumber < weights[i]) return statuses[i]; + randomNumber -= weights[i]; + } + + return PaymentDisburseStatus.Confirmed; + } + + private static T GetRandomAndRemove(List list, Random random) + { + int index = random.Next(list.Count); + T value = list[index]; + list.RemoveAt(index); + return value; + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/PaymentMethodSeeder.cs b/Infrastructure/Database/Demo/PaymentMethodSeeder.cs new file mode 100644 index 0000000..41584fe --- /dev/null +++ b/Infrastructure/Database/Demo/PaymentMethodSeeder.cs @@ -0,0 +1,28 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PaymentMethodSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PaymentMethod.AnyAsync()) return; + + var paymentMethods = new List + { + new PaymentMethod { Name = "Credit Card" }, + new PaymentMethod { Name = "Debit Card" }, + new PaymentMethod { Name = "Bank Transfer" }, + new PaymentMethod { Name = "PayPal" }, + new PaymentMethod { Name = "Cash" } + }; + + foreach (var paymentMethod in paymentMethods) + { + await context.PaymentMethod.AddAsync(paymentMethod); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/PaymentReceiveSeeder.cs b/Infrastructure/Database/Demo/PaymentReceiveSeeder.cs new file mode 100644 index 0000000..22d50fb --- /dev/null +++ b/Infrastructure/Database/Demo/PaymentReceiveSeeder.cs @@ -0,0 +1,117 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PaymentReceiveSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PaymentReceive.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(PaymentReceive); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedInvoices = await context.Invoice + .Include(i => i.SalesOrder) + .Where(i => i.InvoiceStatus == InvoiceStatus.Confirmed && i.SalesOrder != null) + .ToListAsync(); + + var paymentMethods = await context.PaymentMethod + .Select(pm => pm.Id) + .ToListAsync(); + + if (!paymentMethods.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] paymentDates = GetRandomDays(date.Year, date.Month, 4); + + foreach (var paymentDate in paymentDates) + { + if (confirmedInvoices.Count == 0) break; + + var status = GetRandomStatus(random); + var invoice = GetRandomAndRemove(confirmedInvoices, random); + var paymentMethodId = GetRandomValue(paymentMethods, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var paymentReceive = new PaymentReceive + { + AutoNumber = autoNo, + Description = $"Payment Received on {paymentDate:MMMM yyyy}", + PaymentDate = paymentDate, + PaymentMethodId = paymentMethodId, + PaymentAmount = invoice?.SalesOrder?.AfterTaxAmount, + Status = status, + InvoiceId = invoice?.Id + }; + + await context.PaymentReceive.AddAsync(paymentReceive); + } + } + + await context.SaveChangesAsync(); + } + + private static PaymentReceiveStatus GetRandomStatus(Random random) + { + var statuses = new[] + { + PaymentReceiveStatus.Draft, + PaymentReceiveStatus.Cancelled, + PaymentReceiveStatus.Confirmed, + PaymentReceiveStatus.Archived + }; + var weights = new[] { 1, 1, 4, 1 }; + + int totalWeight = weights.Sum(); + int randomNumber = random.Next(0, totalWeight); + + for (int i = 0; i < statuses.Length; i++) + { + if (randomNumber < weights[i]) return statuses[i]; + randomNumber -= weights[i]; + } + + return PaymentReceiveStatus.Confirmed; + } + + private static T GetRandomAndRemove(List list, Random random) + { + int index = random.Next(list.Count); + T value = list[index]; + list.RemoveAt(index); + return value; + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/ProductGroupSeeder.cs b/Infrastructure/Database/Demo/ProductGroupSeeder.cs new file mode 100644 index 0000000..d4b9f1f --- /dev/null +++ b/Infrastructure/Database/Demo/ProductGroupSeeder.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class ProductGroupSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.ProductGroup.AnyAsync()) return; + + var productGroups = new List + { + new ProductGroup { Name = "Hardware" }, + new ProductGroup { Name = "Networking" }, + new ProductGroup { Name = "Storage" }, + new ProductGroup { Name = "Device" }, + new ProductGroup { Name = "Software" }, + new ProductGroup { Name = "Service" } + }; + + foreach (var productGroup in productGroups) + { + await context.ProductGroup.AddAsync(productGroup); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/ProductSeeder.cs b/Infrastructure/Database/Demo/ProductSeeder.cs new file mode 100644 index 0000000..2526dff --- /dev/null +++ b/Infrastructure/Database/Demo/ProductSeeder.cs @@ -0,0 +1,76 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class ProductSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Product.AnyAsync()) return; + + var productGroups = await context.ProductGroup.ToListAsync(); + var measureId = await context.UnitMeasure + .Where(x => x.Name == "unit") + .Select(x => x.Id) + .FirstOrDefaultAsync(); + + if (string.IsNullOrEmpty(measureId)) return; + + var groupMapping = productGroups + .Where(pg => !string.IsNullOrEmpty(pg.Name) && !string.IsNullOrEmpty(pg.Id)) + .ToDictionary(pg => pg.Name!, pg => pg.Id!); + + var entityName = nameof(Product); + var products = new List + { + // Hardware - Gunakan akhiran 'm' untuk decimal literal + new Product { Name = "Dell Servers", UnitPrice = 5000m, ProductGroupId = groupMapping["Hardware"] }, + new Product { Name = "Dell Desktop Computers", UnitPrice = 2000m, ProductGroupId = groupMapping["Hardware"] }, + new Product { Name = "Dell Laptops", UnitPrice = 3000m, ProductGroupId = groupMapping["Hardware"] }, + + // Networking + new Product { Name = "Network Cables", UnitPrice = 100m, ProductGroupId = groupMapping["Networking"] }, + new Product { Name = "Routers and Switches", UnitPrice = 1000m, ProductGroupId = groupMapping["Networking"] }, + new Product { Name = "Antennas and Signal Boosters", UnitPrice = 2000m, ProductGroupId = groupMapping["Networking"] }, + new Product { Name = "Wifii", UnitPrice = 1000m, ProductGroupId = groupMapping["Networking"] }, + + // Storage + new Product { Name = "HDD 500", UnitPrice = 500m, ProductGroupId = groupMapping["Storage"] }, + new Product { Name = "HDD 1T", UnitPrice = 800m, ProductGroupId = groupMapping["Storage"] }, + new Product { Name = "SSD 500", UnitPrice = 1000m, ProductGroupId = groupMapping["Storage"] }, + new Product { Name = "SSD 1T", UnitPrice = 1500m, ProductGroupId = groupMapping["Storage"] }, + + // Device + new Product { Name = "Dell Keyboard", UnitPrice = 700m, ProductGroupId = groupMapping["Device"] }, + new Product { Name = "Dell Mouse", UnitPrice = 500m, ProductGroupId = groupMapping["Device"] }, + new Product { Name = "Dell Monitor 27inch", UnitPrice = 1000m, ProductGroupId = groupMapping["Device"] }, + new Product { Name = "Dell Monitor 32inch", UnitPrice = 1500m, ProductGroupId = groupMapping["Device"] }, + new Product { Name = "Dell Webcams", UnitPrice = 500m, ProductGroupId = groupMapping["Device"] }, + + // Software + new Product { Name = "D365 License", UnitPrice = 800m, Physical = false, ProductGroupId = groupMapping["Software"] }, + + // Service + new Product { Name = "IT Security", UnitPrice = 500m, Physical = false, ProductGroupId = groupMapping["Service"] }, + new Product { Name = "Discount", UnitPrice = -10m, Physical = false, ProductGroupId = groupMapping["Service"] } + }; + + foreach (var product in products) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + product.AutoNumber = autoNo; + product.UnitMeasureId = measureId; + product.Physical ??= true; + + await context.Product.AddAsync(product); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/ProgramManagerResourceSeeder.cs b/Infrastructure/Database/Demo/ProgramManagerResourceSeeder.cs new file mode 100644 index 0000000..f66a9ba --- /dev/null +++ b/Infrastructure/Database/Demo/ProgramManagerResourceSeeder.cs @@ -0,0 +1,28 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class ProgramManagerResourceSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.ProgramManagerResource.AnyAsync()) return; + + var programResources = new List + { + new ProgramManagerResource { Name = "Information Technology" }, + new ProgramManagerResource { Name = "Human Resource" }, + new ProgramManagerResource { Name = "Operations" }, + new ProgramManagerResource { Name = "Sales Marketing" }, + new ProgramManagerResource { Name = "Finance Accounting" } + }; + + foreach (var programResource in programResources) + { + await context.ProgramManagerResource.AddAsync(programResource); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/ProgramManagerSeeder.cs b/Infrastructure/Database/Demo/ProgramManagerSeeder.cs new file mode 100644 index 0000000..d1c6a72 --- /dev/null +++ b/Infrastructure/Database/Demo/ProgramManagerSeeder.cs @@ -0,0 +1,75 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class ProgramManagerSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.ProgramManager.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(ProgramManager); + var programManagerResources = await context.ProgramManagerResource.Select(x => x.Id).ToListAsync(); + + if (!programManagerResources.Any()) return; + + var statusValues = Enum.GetValues(typeof(ProgramManagerStatus)).Cast().ToList(); + var priorityValues = Enum.GetValues(typeof(ProgramManagerPriority)).Cast().ToList(); + + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-6).Year, dateFinish.AddMonths(-6).Month, 1); + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] transactionDates = GetRandomDays(date.Year, date.Month, 12); + + foreach (DateTime transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var programManager = new ProgramManager + { + Title = autoNo, + AutoNumber = autoNo, + Summary = $"Lorem Ipsum Dolor Sit Amet Program - #{Guid.NewGuid().ToString().Substring(0, 5)}", + ProgramManagerResourceId = GetRandomValue(programManagerResources, random), + Status = statusValues[random.Next(statusValues.Count)], + Priority = priorityValues[random.Next(priorityValues.Count)] + }; + + await context.ProgramManager.AddAsync(programManager); + } + } + + await context.SaveChangesAsync(); + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/PurchaseOrderSeeder.cs b/Infrastructure/Database/Demo/PurchaseOrderSeeder.cs new file mode 100644 index 0000000..f590515 --- /dev/null +++ b/Infrastructure/Database/Demo/PurchaseOrderSeeder.cs @@ -0,0 +1,112 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PurchaseOrderSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PurchaseOrder.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(PurchaseOrder); + var vendors = await context.Vendor.Select(x => x.Id).ToListAsync(); + var taxes = await context.Tax.Select(x => x.Id).ToListAsync(); + var products = await context.Product.ToListAsync(); + + if (!vendors.Any() || !taxes.Any() || !products.Any()) return; + + var allStatusValues = Enum.GetValues(typeof(PurchaseOrderStatus)) + .Cast() + .ToList(); + + var nonConfirmedStatusValues = allStatusValues + .Where(x => x != PurchaseOrderStatus.Confirmed) + .ToList(); + + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] transactionDates = GetRandomDays(date.Year, date.Month, 6); + + foreach (DateTime transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + PurchaseOrderStatus selectedStatus; + int probability = random.Next(1, 101); + + if (probability <= 90) + { + selectedStatus = PurchaseOrderStatus.Confirmed; + } + else + { + selectedStatus = nonConfirmedStatusValues[random.Next(nonConfirmedStatusValues.Count)]; + } + + var purchaseOrder = new PurchaseOrder + { + AutoNumber = autoNo, + OrderDate = transDate, + OrderStatus = selectedStatus, + VendorId = GetRandomValue(vendors, random), + TaxId = GetRandomValue(taxes, random), + }; + await context.PurchaseOrder.AddAsync(purchaseOrder); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = products[random.Next(products.Count)]; + var quantity = random.Next(20, 50); + var purchaseOrderItem = new PurchaseOrderItem + { + PurchaseOrderId = purchaseOrder.Id, + ProductId = product.Id, + Summary = product.AutoNumber, + UnitPrice = product.UnitPrice, + Quantity = (double)quantity, + Total = product.UnitPrice * (decimal)quantity + }; + await context.PurchaseOrderItem.AddAsync(purchaseOrderItem); + } + + await context.SaveChangesAsync(); + + context.RecalculatePurchaseOrder(purchaseOrder.Id); + await context.SaveChangesAsync(); + } + } + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/SalesOrderSeeder.cs b/Infrastructure/Database/Demo/SalesOrderSeeder.cs new file mode 100644 index 0000000..d9676c1 --- /dev/null +++ b/Infrastructure/Database/Demo/SalesOrderSeeder.cs @@ -0,0 +1,107 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class SalesOrderSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.SalesOrder.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(SalesOrder); + var customers = await context.Customer.Select(x => x.Id).ToListAsync(); + var taxes = await context.Tax.Select(x => x.Id).ToListAsync(); + var products = await context.Product.ToListAsync(); + + if (!customers.Any() || !taxes.Any() || !products.Any()) return; + + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] transactionDates = GetRandomDays(date.Year, date.Month, 6); + + foreach (DateTime transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var salesOrder = new SalesOrder + { + AutoNumber = autoNo, + OrderDate = transDate, + OrderStatus = GetHighProbabilityStatus(random), + CustomerId = GetRandomValue(customers, random), + TaxId = GetRandomValue(taxes, random), + }; + await context.SalesOrder.AddAsync(salesOrder); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var qty = (double)random.Next(2, 5); + var product = products[random.Next(products.Count)]; + var salesOrderItem = new SalesOrderItem + { + SalesOrderId = salesOrder.Id, + ProductId = product.Id, + Summary = product.AutoNumber, + UnitPrice = product.UnitPrice, + Quantity = qty, + Total = (product.UnitPrice ?? 0m) * (decimal)qty + }; + await context.SalesOrderItem.AddAsync(salesOrderItem); + } + + await context.SaveChangesAsync(); + + context.RecalculateSalesOrder(salesOrder.Id); + await context.SaveChangesAsync(); + } + } + } + + private static SalesOrderStatus GetHighProbabilityStatus(Random random) + { + int probability = random.Next(1, 101); + + if (probability <= 90) + { + return SalesOrderStatus.Confirmed; + } + else + { + var otherStatuses = new[] { SalesOrderStatus.Draft, SalesOrderStatus.Cancelled, SalesOrderStatus.Archived }; + return otherStatuses[random.Next(otherStatuses.Length)]; + } + } + + private static T GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + int daysInMonthCount = DateTime.DaysInMonth(year, month); + var daysInMonth = Enumerable.Range(1, daysInMonthCount).ToList(); + var selectedDays = new List(); + + for (int i = 0; i < count && daysInMonth.Count > 0; i++) + { + int day = daysInMonth[random.Next(daysInMonth.Count)]; + selectedDays.Add(day); + daysInMonth.Remove(day); + } + + return selectedDays.Select(day => new DateTime(year, month, day)).ToArray(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/UnitMeasureSeeder.cs b/Infrastructure/Database/Demo/UnitMeasureSeeder.cs new file mode 100644 index 0000000..c8dc161 --- /dev/null +++ b/Infrastructure/Database/Demo/UnitMeasureSeeder.cs @@ -0,0 +1,28 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class UnitMeasureSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.UnitMeasure.AnyAsync()) return; + + var unitMeasures = new List + { + new UnitMeasure { Name = "m" }, + new UnitMeasure { Name = "kg" }, + new UnitMeasure { Name = "hour" }, + new UnitMeasure { Name = "unit" }, + new UnitMeasure { Name = "pcs" } + }; + + foreach (var unitMeasure in unitMeasures) + { + await context.UnitMeasure.AddAsync(unitMeasure); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/UserSeeder.cs b/Infrastructure/Database/Demo/UserSeeder.cs new file mode 100644 index 0000000..76c18be --- /dev/null +++ b/Infrastructure/Database/Demo/UserSeeder.cs @@ -0,0 +1,54 @@ +using DocumentFormat.OpenXml.InkML; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.Authorization.Identity; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class UserSeeder +{ + public static async Task GenerateDataAsync(UserManager userManager, AppDbContext context, IConfiguration configuration) + { + + var adminSettings = configuration.GetSection("IdentitySettings:DefaultAdmin").Get(); + if (adminSettings == null) return; + + if (context.Users.Where(x => x.Email != adminSettings.Email).Any()) return; + + var userNames = new List + { + "Alex", "Taylor", "Jordan", "Morgan", "Riley", + "Casey", "Peyton", "Cameron", "Jamie", "Drew", + "Dakota", "Avery", "Quinn", "Harper", "Rowan", + "Emerson", "Finley", "Skyler", "Charlie", "Sage" + }; + + var defaultPassword = "123456"; + var domain = "@example.com"; + var role = ApplicationRoles.Member; + + foreach (var name in userNames) + { + var email = $"{name.ToLower()}{domain}"; + + if (await userManager.FindByEmailAsync(email) == null) + { + var applicationUser = new ApplicationUser + { + UserName = email, + Email = email, + FullName = name, + EmailConfirmed = true + }; + + var result = await userManager.CreateAsync(applicationUser, defaultPassword); + + if (result.Succeeded) + { + await userManager.AddToRoleAsync(applicationUser, role); + } + } + } + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/VendorCategorySeeder.cs b/Infrastructure/Database/Demo/VendorCategorySeeder.cs new file mode 100644 index 0000000..d52164f --- /dev/null +++ b/Infrastructure/Database/Demo/VendorCategorySeeder.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class VendorCategorySeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.VendorCategory.AnyAsync()) return; + + var vendorCategories = new List + { + new VendorCategory { Name = "Large" }, + new VendorCategory { Name = "Medium" }, + new VendorCategory { Name = "Small" }, + new VendorCategory { Name = "Specialty" }, + new VendorCategory { Name = "Local" }, + new VendorCategory { Name = "Global" } + }; + + foreach (var category in vendorCategories) + { + await context.VendorCategory.AddAsync(category); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/VendorContactSeeder.cs b/Infrastructure/Database/Demo/VendorContactSeeder.cs new file mode 100644 index 0000000..3a2de02 --- /dev/null +++ b/Infrastructure/Database/Demo/VendorContactSeeder.cs @@ -0,0 +1,75 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class VendorContactSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.VendorContact.AnyAsync()) return; + + var firstNames = new string[] + { + "Adam", "Sarah", "Michael", "Emily", "David", "Jessica", + "Kevin", "Samantha", "Jason", "Olivia", "Matthew", "Ashley", + "Christopher", "Jennifer", "Nicholas", "Amanda", "Alexander", + "Stephanie", "Jonathan", "Lauren" + }; + + var lastNames = new string[] + { + "Johnson", "Williams", "Brown", "Jones", "Miller", "Davis", + "Garcia", "Rodriguez", "Wilson", "Martinez", "Anderson", "Taylor", + "Thomas", "Hernandez", "Moore", "Martin", "Jackson", "Thompson", + "White", "Lopez" + }; + + var jobTitles = new string[] + { + "Chief Executive Officer", "Data Scientist", "Product Manager", "Business Development Executive", + "IT Consultant", "Social Media Specialist", "Research Analyst", "Content Writer", + "Operations Manager", "Financial Planner", "Software Developer", "Vendor Success Manager", + "Marketing Coordinator", "Quality Assurance Tester", "HR Specialist", "Event Coordinator", + "Account Executive", "Network Administrator", "Sales Manager", "Legal Assistant" + }; + + var vendorIds = await context.Vendor.Select(x => x.Id).ToListAsync(); + var random = new Random(); + var entityName = nameof(VendorContact); + + foreach (var vendorId in vendorIds) + { + for (int i = 0; i < 3; i++) + { + var firstName = GetRandomString(firstNames, random); + var lastName = GetRandomString(lastNames, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var contact = new VendorContact + { + Name = $"{firstName} {lastName}", + AutoNumber = autoNo, + VendorId = vendorId, + JobTitle = GetRandomString(jobTitles, random), + EmailAddress = $"{firstName.ToLower()}.{lastName.ToLower()}@gmail.com", + PhoneNumber = $"+1-{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}" + }; + + await context.VendorContact.AddAsync(contact); + } + } + + await context.SaveChangesAsync(); + } + + private static string GetRandomString(string[] array, Random random) + { + return array[random.Next(array.Length)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/VendorGroupSeeder.cs b/Infrastructure/Database/Demo/VendorGroupSeeder.cs new file mode 100644 index 0000000..7f4fb20 --- /dev/null +++ b/Infrastructure/Database/Demo/VendorGroupSeeder.cs @@ -0,0 +1,28 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class VendorGroupSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.VendorGroup.AnyAsync()) return; + + var vendorGroups = new List + { + new VendorGroup { Name = "Manufacture" }, + new VendorGroup { Name = "Supplier" }, + new VendorGroup { Name = "Service Provider" }, + new VendorGroup { Name = "Distributor" }, + new VendorGroup { Name = "Freelancer" } + }; + + foreach (var group in vendorGroups) + { + await context.VendorGroup.AddAsync(group); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/VendorSeeder.cs b/Infrastructure/Database/Demo/VendorSeeder.cs new file mode 100644 index 0000000..f7e1031 --- /dev/null +++ b/Infrastructure/Database/Demo/VendorSeeder.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class VendorSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Vendor.AnyAsync()) return; + + var groups = await context.VendorGroup.Select(x => x.Id).ToArrayAsync(); + var categories = await context.VendorCategory.Select(x => x.Id).ToArrayAsync(); + + var cities = new string[] { "New York", "Los Angeles", "San Francisco", "Chicago" }; + var streets = new string[] { "Main Street", "Broadway", "Elm Street", "Maple Avenue" }; + var states = new string[] { "NY", "CA", "IL", "TX" }; + var zipCodes = new string[] { "10001", "90001", "60601", "73301" }; + var phoneNumbers = new string[] { "123-456-7890", "987-654-3210", "555-123-4567", "111-222-3333" }; + var emails = new string[] { "vendor1@example.com", "vendor2@example.com", "vendor3@example.com", "vendor4@example.com" }; + + var random = new Random(); + var entityName = nameof(Vendor); + + var vendors = new List + { + new Vendor { Name = "Quantum Industries" }, + new Vendor { Name = "Apex Ventures" }, + new Vendor { Name = "Horizon Enterprises" }, + new Vendor { Name = "Nova Innovations" }, + new Vendor { Name = "Phoenix Holdings" }, + new Vendor { Name = "Titan Group" }, + new Vendor { Name = "Zenith Corporation" }, + new Vendor { Name = "Prime Solutions" }, + new Vendor { Name = "Cascade Enterprises" }, + new Vendor { Name = "Aurora Holdings" }, + new Vendor { Name = "Vanguard Industries" }, + new Vendor { Name = "Empyrean Ventures" }, + new Vendor { Name = "Genesis Corporation" }, + new Vendor { Name = "Equinox Enterprises" }, + new Vendor { Name = "Summit Holdings" }, + new Vendor { Name = "Sovereign Solutions" }, + new Vendor { Name = "Spectrum Corporation" }, + new Vendor { Name = "Elysium Enterprises" }, + new Vendor { Name = "Infinity Holdings" }, + new Vendor { Name = "Momentum Ventures" } + }; + + foreach (var vendor in vendors) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + vendor.AutoNumber = autoNo; + vendor.VendorGroupId = GetRandomValue(groups, random); + vendor.VendorCategoryId = GetRandomValue(categories, random); + vendor.City = GetRandomString(cities, random); + vendor.Street = GetRandomString(streets, random); + vendor.State = GetRandomString(states, random); + vendor.ZipCode = GetRandomString(zipCodes, random); + vendor.PhoneNumber = GetRandomString(phoneNumbers, random); + vendor.EmailAddress = GetRandomString(emails, random); + + await context.Vendor.AddAsync(vendor); + } + + await context.SaveChangesAsync(); + } + + private static T GetRandomValue(T[] array, Random random) + { + return array[random.Next(array.Length)]; + } + + private static string GetRandomString(string[] array, Random random) + { + return array[random.Next(array.Length)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/WarehouseSeeder.cs b/Infrastructure/Database/Demo/WarehouseSeeder.cs new file mode 100644 index 0000000..68f9947 --- /dev/null +++ b/Infrastructure/Database/Demo/WarehouseSeeder.cs @@ -0,0 +1,27 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class WarehouseSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Warehouse.Where(x => x.SystemWarehouse == false).AnyAsync()) return; + + var warehouses = new List + { + new Warehouse { Name = "New York" }, + new Warehouse { Name = "San Francisco" }, + new Warehouse { Name = "Chicago" }, + new Warehouse { Name = "Los Angeles" } + }; + + foreach (var warehouse in warehouses) + { + await context.Warehouse.AddAsync(warehouse); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs new file mode 100644 index 0000000..ccc867b --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs @@ -0,0 +1,68 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ApplicationUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.FullName).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.SsoIdFirebase).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdKeycloak).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdAzure).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdAws).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther1).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther2).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther3).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.AvatarFile).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.RefreshToken).HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.Property(e => e.FirstName).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.LastName).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.ShortBio).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.JobTitle).HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Country) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaX) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaFacebook) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaInstagram) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaTikTok) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs new file mode 100644 index 0000000..0d69f8d --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs @@ -0,0 +1,25 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class AutoNumberSequenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.EntityName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PrefixTemplate) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SuffixTemplate) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.EntityName, e.Year, e.Month }) + .IsUnique(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/BillConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BillConfiguration.cs new file mode 100644 index 0000000..6420761 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BillConfiguration.cs @@ -0,0 +1,30 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class BillConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.PurchaseOrderId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.PurchaseOrder) + .WithMany() + .HasForeignKey(e => e.PurchaseOrderId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.BillDate); + builder.HasIndex(e => e.PurchaseOrderId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/BookingConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BookingConfiguration.cs new file mode 100644 index 0000000..a505974 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BookingConfiguration.cs @@ -0,0 +1,53 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class BookingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Subject) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StartTimezone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EndTimezone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Location) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.RecurrenceRule) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.RecurrenceID) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FollowingID) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.RecurrenceException) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.BookingResourceId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.BookingResource) + .WithMany() + .HasForeignKey(e => e.BookingResourceId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.BookingResourceId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/BookingGroupConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BookingGroupConfiguration.cs new file mode 100644 index 0000000..d1f3276 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BookingGroupConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class BookingGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/BookingResourceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BookingResourceConfiguration.cs new file mode 100644 index 0000000..2e963f9 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BookingResourceConfiguration.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class BookingResourceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.BookingGroupId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.BookingGroup) + .WithMany() + .HasForeignKey(e => e.BookingGroupId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.BookingGroupId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs new file mode 100644 index 0000000..7df162c --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs @@ -0,0 +1,87 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CompanyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CurrencyId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Currency) + .WithMany() + .HasForeignKey(e => e.CurrencyId) + .OnDelete(DeleteBehavior.NoAction); + + builder.Property(e => e.TaxIdentification) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.BusinessLicense) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyLogo) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Phone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Email) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaX) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaFacebook) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaInstagram) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaTikTok) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.Email); + builder.HasIndex(e => e.CurrencyId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs new file mode 100644 index 0000000..0998cec --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs @@ -0,0 +1,36 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CurrencyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Symbol) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CountryOwner) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.Code).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CustomerCategoryConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CustomerCategoryConfiguration.cs new file mode 100644 index 0000000..c54f2b2 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CustomerCategoryConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CustomerCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CustomerConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CustomerConfiguration.cs new file mode 100644 index 0000000..d03eb2b --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CustomerConfiguration.cs @@ -0,0 +1,87 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.Street) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.State) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Country) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FaxNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmailAddress) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Website) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.WhatsApp) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.LinkedIn) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Facebook) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Instagram) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.TwitterX) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.TikTok) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CustomerGroupId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.CustomerCategoryId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.CustomerGroup) + .WithMany() + .HasForeignKey(e => e.CustomerGroupId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.CustomerCategory) + .WithMany() + .HasForeignKey(e => e.CustomerCategoryId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.CustomerGroupId); + builder.HasIndex(e => e.CustomerCategoryId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CustomerContactConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CustomerContactConfiguration.cs new file mode 100644 index 0000000..6a8e1cc --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CustomerContactConfiguration.cs @@ -0,0 +1,42 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CustomerContactConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.JobTitle) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmailAddress) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CustomerId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Customer) + .WithMany(e => e.CustomerContactList) + .HasForeignKey(e => e.CustomerId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.CustomerId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CustomerGroupConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CustomerGroupConfiguration.cs new file mode 100644 index 0000000..3966b27 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CustomerGroupConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CustomerGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/InventoryTransactionConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/InventoryTransactionConfiguration.cs new file mode 100644 index 0000000..d45fed5 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/InventoryTransactionConfiguration.cs @@ -0,0 +1,64 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class InventoryTransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.ModuleId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ModuleName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ModuleCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ModuleNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.WarehouseId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.WarehouseFromId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.WarehouseToId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Warehouse) + .WithMany() + .HasForeignKey(e => e.WarehouseId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Product) + .WithMany() + .HasForeignKey(e => e.ProductId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.WarehouseFrom) + .WithMany() + .HasForeignKey(e => e.WarehouseFromId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.WarehouseTo) + .WithMany() + .HasForeignKey(e => e.WarehouseToId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.MovementDate); + builder.HasIndex(e => e.WarehouseId); + builder.HasIndex(e => e.ProductId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/InvoiceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/InvoiceConfiguration.cs new file mode 100644 index 0000000..8a79df4 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/InvoiceConfiguration.cs @@ -0,0 +1,30 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class InvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SalesOrderId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.SalesOrder) + .WithMany() + .HasForeignKey(e => e.SalesOrderId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.InvoiceDate); + builder.HasIndex(e => e.SalesOrderId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PaymentDisburseConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PaymentDisburseConfiguration.cs new file mode 100644 index 0000000..427bbc1 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PaymentDisburseConfiguration.cs @@ -0,0 +1,39 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PaymentDisburseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.BillId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.PaymentMethodId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Bill) + .WithMany() + .HasForeignKey(e => e.BillId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.PaymentMethod) + .WithMany() + .HasForeignKey(e => e.PaymentMethodId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.BillId); + builder.HasIndex(e => e.PaymentMethodId); + builder.HasIndex(e => e.PaymentDate); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PaymentMethodConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PaymentMethodConfiguration.cs new file mode 100644 index 0000000..c2f8ac6 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PaymentMethodConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PaymentMethodConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PaymentReceiveConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PaymentReceiveConfiguration.cs new file mode 100644 index 0000000..fa010ca --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PaymentReceiveConfiguration.cs @@ -0,0 +1,39 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PaymentReceiveConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.InvoiceId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.PaymentMethodId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Invoice) + .WithMany() + .HasForeignKey(e => e.InvoiceId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.PaymentMethod) + .WithMany() + .HasForeignKey(e => e.PaymentMethodId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.InvoiceId); + builder.HasIndex(e => e.PaymentMethodId); + builder.HasIndex(e => e.PaymentDate); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ProductConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ProductConfiguration.cs new file mode 100644 index 0000000..4a6f688 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ProductConfiguration.cs @@ -0,0 +1,42 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.UnitMeasureId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductGroupId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.UnitMeasure) + .WithMany() + .HasForeignKey(e => e.UnitMeasureId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.ProductGroup) + .WithMany() + .HasForeignKey(e => e.ProductGroupId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.UnitMeasureId); + builder.HasIndex(e => e.ProductGroupId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ProductGroupConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ProductGroupConfiguration.cs new file mode 100644 index 0000000..3cbe7e3 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ProductGroupConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ProductGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ProgramManagerConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ProgramManagerConfiguration.cs new file mode 100644 index 0000000..50b1649 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ProgramManagerConfiguration.cs @@ -0,0 +1,33 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ProgramManagerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Title) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.ProgramManagerResourceId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.ProgramManagerResource) + .WithMany() + .HasForeignKey(e => e.ProgramManagerResourceId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Title); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.ProgramManagerResourceId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ProgramManagerResourceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ProgramManagerResourceConfiguration.cs new file mode 100644 index 0000000..ab29fe5 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ProgramManagerResourceConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ProgramManagerResourceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..176ae7a --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderConfiguration.cs @@ -0,0 +1,44 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PurchaseOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.VendorId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.TaxId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Vendor) + .WithMany() + .HasForeignKey(e => e.VendorId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Tax) + .WithMany() + .HasForeignKey(e => e.TaxId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.PurchaseOrderItemList) + .WithOne(e => e.PurchaseOrder) + .HasForeignKey(e => e.PurchaseOrderId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.OrderDate); + builder.HasIndex(e => e.VendorId); + builder.HasIndex(e => e.TaxId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderItemConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderItemConfiguration.cs new file mode 100644 index 0000000..7928fca --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PurchaseOrderItemConfiguration.cs @@ -0,0 +1,34 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PurchaseOrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.PurchaseOrderId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.PurchaseOrder) + .WithMany(e => e.PurchaseOrderItemList) + .HasForeignKey(e => e.PurchaseOrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Product) + .WithMany() + .HasForeignKey(e => e.ProductId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.PurchaseOrderId); + builder.HasIndex(e => e.ProductId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesOrderConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesOrderConfiguration.cs new file mode 100644 index 0000000..5eb4600 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesOrderConfiguration.cs @@ -0,0 +1,44 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class SalesOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CustomerId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.TaxId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Customer) + .WithMany() + .HasForeignKey(e => e.CustomerId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Tax) + .WithMany() + .HasForeignKey(e => e.TaxId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.SalesOrderItemList) + .WithOne(e => e.SalesOrder) + .HasForeignKey(e => e.SalesOrderId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.OrderDate); + builder.HasIndex(e => e.CustomerId); + builder.HasIndex(e => e.TaxId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesOrderItemConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesOrderItemConfiguration.cs new file mode 100644 index 0000000..f80327e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesOrderItemConfiguration.cs @@ -0,0 +1,34 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class SalesOrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.SalesOrderId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.SalesOrder) + .WithMany(e => e.SalesOrderItemList) + .HasForeignKey(e => e.SalesOrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Product) + .WithMany() + .HasForeignKey(e => e.ProductId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.SalesOrderId); + builder.HasIndex(e => e.ProductId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs new file mode 100644 index 0000000..d037d7e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs @@ -0,0 +1,39 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SerilogLogs"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.Message) + .HasMaxLength(2000); + + builder.Property(e => e.Level) + .HasMaxLength(128); + + builder.Property(e => e.TimeStamp) + .IsRequired(); + + builder.Property(e => e.Exception) + .HasColumnType("nvarchar(max)"); + + builder.Property(e => e.Properties) + .HasColumnType("nvarchar(max)"); + + builder.Property(e => e.MessageTemplate) + .HasMaxLength(2000); + + builder.Property(e => e.LogEvent) + .HasColumnType("nvarchar(max)"); + + builder.HasIndex(e => e.TimeStamp) + .HasDatabaseName("IX_SerilogLogs_TimeStamp"); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs new file mode 100644 index 0000000..2134cd5 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs @@ -0,0 +1,36 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class TaxConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PercentageValue) + .HasColumnType("decimal(18,2)"); + + builder.Property(e => e.Category) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.Code).IsUnique(); + builder.HasIndex(e => e.Name); + } +} diff --git a/Infrastructure/Database/MsSQL/Configuration/TodoConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TodoConfiguration.cs new file mode 100644 index 0000000..cd254ce --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TodoConfiguration.cs @@ -0,0 +1,25 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class TodoConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasMany(e => e.TodoItemList) + .WithOne(e => e.Todo) + .HasForeignKey(e => e.TodoId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/TodoItemConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TodoItemConfiguration.cs new file mode 100644 index 0000000..06c56fe --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TodoItemConfiguration.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class TodoItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.TodoId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Todo) + .WithMany(e => e.TodoItemList) + .HasForeignKey(e => e.TodoId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.TodoId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/UnitMeasureConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/UnitMeasureConfiguration.cs new file mode 100644 index 0000000..7c704de --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/UnitMeasureConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class UnitMeasureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/VendorCategoryConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/VendorCategoryConfiguration.cs new file mode 100644 index 0000000..6b63777 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/VendorCategoryConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class VendorCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/VendorConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/VendorConfiguration.cs new file mode 100644 index 0000000..de8e956 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/VendorConfiguration.cs @@ -0,0 +1,92 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class VendorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.Street) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.State) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Country) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FaxNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmailAddress) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Website) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.WhatsApp) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.LinkedIn) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Facebook) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Instagram) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.TwitterX) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.TikTok) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.VendorGroupId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.VendorCategoryId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.VendorGroup) + .WithMany() + .HasForeignKey(e => e.VendorGroupId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.VendorCategory) + .WithMany() + .HasForeignKey(e => e.VendorCategoryId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.VendorContactList) + .WithOne(e => e.Vendor) + .HasForeignKey(e => e.VendorId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.VendorGroupId); + builder.HasIndex(e => e.VendorCategoryId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/VendorContactConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/VendorContactConfiguration.cs new file mode 100644 index 0000000..d51421f --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/VendorContactConfiguration.cs @@ -0,0 +1,42 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class VendorContactConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.JobTitle) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmailAddress) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.VendorId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Vendor) + .WithMany(e => e.VendorContactList) + .HasForeignKey(e => e.VendorId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.VendorId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/VendorGroupConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/VendorGroupConfiguration.cs new file mode 100644 index 0000000..aa3ca29 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/VendorGroupConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class VendorGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/WarehouseConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/WarehouseConfiguration.cs new file mode 100644 index 0000000..88dc0cf --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/WarehouseConfiguration.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class WarehouseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs b/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs new file mode 100644 index 0000000..89e9723 --- /dev/null +++ b/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs @@ -0,0 +1,23 @@ +namespace Indotalent.Infrastructure.Database.MsSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class MsSQLConfiguration +{ + public static IServiceCollection AddMsSQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseSqlServer(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + return services; + } + + public static void InitializeDatabase(IServiceProvider serviceProvider) where TContext : DbContext + { + using var scope = serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MySQL/MySQLConfiguration.cs b/Infrastructure/Database/MySQL/MySQLConfiguration.cs new file mode 100644 index 0000000..4242c58 --- /dev/null +++ b/Infrastructure/Database/MySQL/MySQLConfiguration.cs @@ -0,0 +1,17 @@ +namespace Indotalent.Infrastructure.Database.MySQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class MySQLConfiguration +{ + public static IServiceCollection AddMySQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseMySQL(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs b/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs new file mode 100644 index 0000000..9bf7ccb --- /dev/null +++ b/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs @@ -0,0 +1,17 @@ +namespace Indotalent.Infrastructure.Database.PostgreSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class PostgreSQLConfiguration +{ + public static IServiceCollection AddPostgreSQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseNpgsql(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Email/DI.cs b/Infrastructure/Email/DI.cs new file mode 100644 index 0000000..12a7561 --- /dev/null +++ b/Infrastructure/Email/DI.cs @@ -0,0 +1,46 @@ + + +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Email.Mailgun; +using Indotalent.Infrastructure.Email.Mailjet; +using Indotalent.Infrastructure.Email.SendGrid; +using Indotalent.Infrastructure.Email.Smtp; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Infrastructure.Email; + +public static class DI +{ + public static IServiceCollection AddEmailService(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + + var emailSettings = configuration.GetSection("EmailSettings").Get() ?? new EmailSettingsModel(); + + if (emailSettings.SendGrid?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Mailgun?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Mailjet?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Smtp?.IsUsed == true) + { + services.AddTransient(); + } + else + { + services.AddScoped(); + } + + services.AddTransient, IdentityEmailManager>(); + + return services; + } +} diff --git a/Infrastructure/Email/DefaultEmailSender.cs b/Infrastructure/Email/DefaultEmailSender.cs new file mode 100644 index 0000000..862823f --- /dev/null +++ b/Infrastructure/Email/DefaultEmailSender.cs @@ -0,0 +1,21 @@ +namespace Indotalent.Infrastructure.Email; + +public class DefaultEmailSender : IEmailSender +{ + private readonly ILogger _logger; + + public DefaultEmailSender(ILogger logger) + { + _logger = logger; + } + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + _logger.LogWarning("Email sending requested but NO active email provider found in EmailSettings."); + _logger.LogInformation("To: {Email}", email); + _logger.LogInformation("Subject: {Subject}", subject); + _logger.LogInformation("Content: {Message}", htmlMessage); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Infrastructure/Email/EmailService.cs b/Infrastructure/Email/EmailService.cs new file mode 100644 index 0000000..7f63634 --- /dev/null +++ b/Infrastructure/Email/EmailService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Email; + +public class EmailService(IOptions options) +{ + private readonly EmailSettingsModel _settings = options.Value; + + public EmailSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/Email/EmailSettingsModel.cs b/Infrastructure/Email/EmailSettingsModel.cs new file mode 100644 index 0000000..33504ff --- /dev/null +++ b/Infrastructure/Email/EmailSettingsModel.cs @@ -0,0 +1,14 @@ +using Indotalent.Infrastructure.Email.Mailgun; +using Indotalent.Infrastructure.Email.Mailjet; +using Indotalent.Infrastructure.Email.SendGrid; +using Indotalent.Infrastructure.Email.Smtp; + +namespace Indotalent.Infrastructure.Email; + +public class EmailSettingsModel +{ + public SendGridSettingsModel SendGrid { get; set; } = new(); + public MailgunSettingsModel Mailgun { get; set; } = new(); + public MailjetSettingsModel Mailjet { get; set; } = new(); + public SmtpSettingsModel Smtp { get; set; } = new(); +} diff --git a/Infrastructure/Email/IEmailSender.cs b/Infrastructure/Email/IEmailSender.cs new file mode 100644 index 0000000..224009d --- /dev/null +++ b/Infrastructure/Email/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Infrastructure.Email; + +public interface IEmailSender +{ + Task SendEmailAsync(string email, string subject, string htmlMessage); +} diff --git a/Infrastructure/Email/IdentityEmailManager.cs b/Infrastructure/Email/IdentityEmailManager.cs new file mode 100644 index 0000000..99fa3cb --- /dev/null +++ b/Infrastructure/Email/IdentityEmailManager.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Infrastructure.Email; + +public class IdentityEmailManager : IEmailSender where TUser : class +{ + private readonly IEmailSender _emailSender; + + public IdentityEmailManager(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink) + { + var message = confirmationLink.Contains("<") + ? confirmationLink + : $"Please confirm your account by clicking this link: Confirm Account"; + + return _emailSender.SendEmailAsync(email, "Account Confirmation", message); + } + + public Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink) + { + var message = resetLink.Contains("<") + ? resetLink + : $"To reset your password, please click the following link: Reset Password"; + + return _emailSender.SendEmailAsync(email, "Password Reset Request", message); + } + + public Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode) => + _emailSender.SendEmailAsync(email, "Your Password Reset Code", + $"Your password reset security code is: {resetCode}. Please enter this code to proceed with the reset process."); +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailgun/MailgunEmailSender.cs b/Infrastructure/Email/Mailgun/MailgunEmailSender.cs new file mode 100644 index 0000000..06cd747 --- /dev/null +++ b/Infrastructure/Email/Mailgun/MailgunEmailSender.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; + +namespace Indotalent.Infrastructure.Email.Mailgun; + +public class MailgunEmailSender : IEmailSender +{ + private readonly MailgunSettingsModel _settings; + private readonly HttpClient _httpClient; + + public MailgunEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.Mailgun; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.Domain)) + { + throw new Exception("Mailgun configuration is missing ApiKey or Domain."); + } + + var authToken = Encoding.ASCII.GetBytes($"api:{_settings.ApiKey}"); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authToken)); + + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("from", _settings.FromEmail), + new KeyValuePair("to", email), + new KeyValuePair("subject", subject), + new KeyValuePair("html", htmlMessage) + }); + + var response = await _httpClient.PostAsync($"https://api.mailgun.net/v3/{_settings.Domain}/messages", formContent); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via Mailgun. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs b/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs new file mode 100644 index 0000000..e2b8dc4 --- /dev/null +++ b/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Email.Mailgun; + +public class MailgunSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/Mailjet/MailjetEmailSender.cs b/Infrastructure/Email/Mailjet/MailjetEmailSender.cs new file mode 100644 index 0000000..d221fdf --- /dev/null +++ b/Infrastructure/Email/Mailjet/MailjetEmailSender.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Indotalent.Infrastructure.Email.Mailjet; + +public class MailjetEmailSender : IEmailSender +{ + private readonly MailjetSettingsModel _settings; + private readonly HttpClient _httpClient; + + public MailjetEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.Mailjet; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.ApiSecret)) + { + throw new Exception("Mailjet configuration is missing ApiKey or ApiSecret."); + } + + var authToken = Encoding.UTF8.GetBytes($"{_settings.ApiKey}:{_settings.ApiSecret}"); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authToken)); + + var payload = new + { + Messages = new[] + { + new + { + From = new { Email = _settings.FromEmail, Name = "Mailjet System" }, + To = new[] { new { Email = email } }, + Subject = subject, + HTMLPart = htmlMessage + } + } + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("https://api.mailjet.com/v3.1/send", content); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via Mailjet. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs b/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs new file mode 100644 index 0000000..fb3b28a --- /dev/null +++ b/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Email.Mailjet; + +public class MailjetSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string ApiSecret { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/SendGrid/SendGridEmailSender.cs b/Infrastructure/Email/SendGrid/SendGridEmailSender.cs new file mode 100644 index 0000000..1117b34 --- /dev/null +++ b/Infrastructure/Email/SendGrid/SendGridEmailSender.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Indotalent.Infrastructure.Email.SendGrid; + +public class SendGridEmailSender : IEmailSender +{ + private readonly SendGridSettingsModel _settings; + private readonly HttpClient _httpClient; + + public SendGridEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.SendGrid; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey)) + { + throw new Exception("SendGrid configuration is missing ApiKey."); + } + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey); + + var payload = new + { + personalizations = new[] + { + new + { + to = new[] { new { email = email } } + } + }, + from = new { email = _settings.FromEmail, name = "SendGrid System" }, + subject = subject, + content = new[] + { + new + { + type = "text/html", + value = htmlMessage + } + } + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("https://api.sendgrid.com/v3/mail/send", content); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via SendGrid. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs b/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs new file mode 100644 index 0000000..8e75081 --- /dev/null +++ b/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Email.SendGrid; + +public class SendGridSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/Smtp/SmtpEmailSender.cs b/Infrastructure/Email/Smtp/SmtpEmailSender.cs new file mode 100644 index 0000000..7a41791 --- /dev/null +++ b/Infrastructure/Email/Smtp/SmtpEmailSender.cs @@ -0,0 +1,54 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace Indotalent.Infrastructure.Email.Smtp; + +public class SmtpEmailSender : IEmailSender +{ + private readonly SmtpSettingsModel _settings; + + public SmtpEmailSender(IOptions emailOptions) + { + _settings = emailOptions.Value.Smtp; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + var message = new MimeMessage(); + + message.From.Add(new MailboxAddress(_settings.FromName, _settings.FromAddress)); + + message.To.Add(MailboxAddress.Parse(email)); + + message.Subject = subject; + + var bodyBuilder = new BodyBuilder + { + HtmlBody = htmlMessage + }; + message.Body = bodyBuilder.ToMessageBody(); + + using var client = new SmtpClient(); + try + { + await client.ConnectAsync(_settings.Host, _settings.Port, SecureSocketOptions.Auto); + + if (!string.IsNullOrEmpty(_settings.UserName)) + { + await client.AuthenticateAsync(_settings.UserName, _settings.Password); + } + + await client.SendAsync(message); + } + catch (Exception ex) + { + throw new Exception($"Failed to send email via SMTP to {email} via {_settings.Host}. Error: {ex.Message}", ex); + } + finally + { + await client.DisconnectAsync(true); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Smtp/SmtpSettingsModel.cs b/Infrastructure/Email/Smtp/SmtpSettingsModel.cs new file mode 100644 index 0000000..cd02f74 --- /dev/null +++ b/Infrastructure/Email/Smtp/SmtpSettingsModel.cs @@ -0,0 +1,12 @@ +namespace Indotalent.Infrastructure.Email.Smtp; + +public class SmtpSettingsModel +{ + public bool IsUsed { get; set; } + public string Host { get; set; } = string.Empty; + public int Port { get; set; } + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FromAddress { get; set; } = string.Empty; + public string FromName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Avatar/AvatarStorageModel.cs b/Infrastructure/File/Avatar/AvatarStorageModel.cs new file mode 100644 index 0000000..964a595 --- /dev/null +++ b/Infrastructure/File/Avatar/AvatarStorageModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Avatar; + +public class AvatarStorageModel +{ + public bool IsUsed { get; set; } + public string StoragePath { get; set; } = "wwwroot/avatars"; + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Aws/AwsS3SettingsModel.cs b/Infrastructure/File/Aws/AwsS3SettingsModel.cs new file mode 100644 index 0000000..99c67d1 --- /dev/null +++ b/Infrastructure/File/Aws/AwsS3SettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.File.Aws; + +public class AwsS3SettingsModel +{ + public bool IsUsed { get; set; } + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string BucketName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Azure/AzureBlobSettingsModel.cs b/Infrastructure/File/Azure/AzureBlobSettingsModel.cs new file mode 100644 index 0000000..95c3a53 --- /dev/null +++ b/Infrastructure/File/Azure/AzureBlobSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Azure; + +public class AzureBlobSettingsModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public string ContainerName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/DI.cs b/Infrastructure/File/DI.cs new file mode 100644 index 0000000..c3dc97e --- /dev/null +++ b/Infrastructure/File/DI.cs @@ -0,0 +1,13 @@ + + +namespace Indotalent.Infrastructure.File; + +public static class DI +{ + public static IServiceCollection AddFileService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/File/Dropbox/DropboxSettingsModel.cs b/Infrastructure/File/Dropbox/DropboxSettingsModel.cs new file mode 100644 index 0000000..36f76a1 --- /dev/null +++ b/Infrastructure/File/Dropbox/DropboxSettingsModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.File.Dropbox; + +public class DropboxSettingsModel +{ + public bool IsUsed { get; set; } + public string AccessToken { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/FileStorageService.cs b/Infrastructure/File/FileStorageService.cs new file mode 100644 index 0000000..6589743 --- /dev/null +++ b/Infrastructure/File/FileStorageService.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.File; + +public class FileStorageService +{ + private readonly FileStorageSettingsModel _settings; + private readonly IWebHostEnvironment _environment; + + private readonly string[] _allowedAvatarExtensions = { ".jpg", ".jpeg", ".png" }; + private readonly string[] _allowedDocExtensions = { ".pdf", ".xls", ".xlsx", ".doc", ".docx", ".txt" }; + + public FileStorageService(IOptions options, IWebHostEnvironment environment) + { + _settings = options.Value; + _environment = environment; + } + + public FileStorageSettingsModel GetConfiguration() => _settings; + + private string GetPhysicalPath(string subFolder) + { + return Path.Combine(_environment.ContentRootPath, "wwwroot", subFolder); + } + + public async Task SaveAvatarAsync(string userId, byte[] fileData, string extension) + { + var avatarSettings = _settings.Avatar; + if (!avatarSettings.IsUsed) throw new Exception("Avatar storage is disabled."); + + var ext = extension.ToLower(); + if (!_allowedAvatarExtensions.Contains(ext)) + throw new Exception("Invalid image type. Only JPG, JPEG, and PNG are allowed."); + + var folderPath = GetPhysicalPath(avatarSettings.StoragePath); + + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + var fileName = $"{Guid.NewGuid()}{ext}"; + var filePath = Path.Combine(folderPath, fileName); + + await System.IO.File.WriteAllBytesAsync(filePath, fileData); + + return fileName; + } + + public void DeleteOldAvatar(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) return; + + try + { + var folderPath = GetPhysicalPath(_settings.Avatar.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + } + catch { } + } + + public async Task SaveFileAsync(byte[] fileData, string extension) + { + var localSettings = _settings.Local; + if (!localSettings.IsUsed) throw new Exception("Local file storage is disabled."); + + var ext = extension.ToLower(); + if (!_allowedDocExtensions.Contains(ext)) + throw new Exception($"File type {ext} is not allowed."); + + var folderPath = GetPhysicalPath(localSettings.StoragePath); + + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + var fileName = $"{Guid.NewGuid()}{ext}"; + var filePath = Path.Combine(folderPath, fileName); + + await System.IO.File.WriteAllBytesAsync(filePath, fileData); + + return fileName; + } + + public void DeleteFile(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) return; + + try + { + var folderPath = GetPhysicalPath(_settings.Local.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + } + catch { } + } +} \ No newline at end of file diff --git a/Infrastructure/File/FileStorageSettingsModel.cs b/Infrastructure/File/FileStorageSettingsModel.cs new file mode 100644 index 0000000..b2aad6b --- /dev/null +++ b/Infrastructure/File/FileStorageSettingsModel.cs @@ -0,0 +1,18 @@ +using Indotalent.Infrastructure.File.Avatar; +using Indotalent.Infrastructure.File.Aws; +using Indotalent.Infrastructure.File.Azure; +using Indotalent.Infrastructure.File.Dropbox; +using Indotalent.Infrastructure.File.Google; +using Indotalent.Infrastructure.File.Local; + +namespace Indotalent.Infrastructure.File; + +public class FileStorageSettingsModel +{ + public AvatarStorageModel Avatar { get; set; } = new(); + public LocalStorageModel Local { get; set; } = new(); + public AwsS3SettingsModel AwsS3 { get; set; } = new(); + public AzureBlobSettingsModel AzureBlob { get; set; } = new(); + public GoogleCloudSettingsModel GoogleCloud { get; set; } = new(); + public DropboxSettingsModel Dropbox { get; set; } = new(); +} diff --git a/Infrastructure/File/Google/GoogleCloudSettingsModel.cs b/Infrastructure/File/Google/GoogleCloudSettingsModel.cs new file mode 100644 index 0000000..72f230c --- /dev/null +++ b/Infrastructure/File/Google/GoogleCloudSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Google; + +public class GoogleCloudSettingsModel +{ + public bool IsUsed { get; set; } + public string ProjectId { get; set; } = string.Empty; + public string BucketName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Local/LocalStorageModel.cs b/Infrastructure/File/Local/LocalStorageModel.cs new file mode 100644 index 0000000..f220017 --- /dev/null +++ b/Infrastructure/File/Local/LocalStorageModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Local; + +public class LocalStorageModel +{ + public bool IsUsed { get; set; } + public string StoragePath { get; set; } = "wwwroot/uploads"; + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/Logging/DI.cs b/Infrastructure/Logging/DI.cs new file mode 100644 index 0000000..7fbc5a1 --- /dev/null +++ b/Infrastructure/Logging/DI.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Logging; + +public static class DI +{ + public static IServiceCollection AddLoggingService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/Logging/LoggerSettingsModel.cs b/Infrastructure/Logging/LoggerSettingsModel.cs new file mode 100644 index 0000000..c61f1b7 --- /dev/null +++ b/Infrastructure/Logging/LoggerSettingsModel.cs @@ -0,0 +1,10 @@ +using Indotalent.Infrastructure.Logging.Serilog; + +namespace Indotalent.Infrastructure.Logging; + +public class LoggerSettingsModel +{ + public FileLoggerModel File { get; set; } = new(); + public DatabaseLoggerModel Database { get; set; } = new(); + public SeqLoggerModel Seq { get; set; } = new(); +} diff --git a/Infrastructure/Logging/LoggingService.cs b/Infrastructure/Logging/LoggingService.cs new file mode 100644 index 0000000..7412192 --- /dev/null +++ b/Infrastructure/Logging/LoggingService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Logging; + +public class LoggingService(IOptions options) +{ + private readonly LoggerSettingsModel _settings = options.Value; + + public LoggerSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs b/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs new file mode 100644 index 0000000..64b8522 --- /dev/null +++ b/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class DatabaseLoggerModel +{ + public bool IsUsed { get; set; } + public string TableName { get; set; } = "SerilogLogs"; + public bool AutoCreateSqlTable { get; set; } = true; +} diff --git a/Infrastructure/Logging/Serilog/FileLoggerModel.cs b/Infrastructure/Logging/Serilog/FileLoggerModel.cs new file mode 100644 index 0000000..b1408a9 --- /dev/null +++ b/Infrastructure/Logging/Serilog/FileLoggerModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class FileLoggerModel +{ + public bool IsUsed { get; set; } + public string Path { get; set; } = "Logs/log-.txt"; + public string RollingInterval { get; set; } = "Day"; // Day, Hour, Month +} diff --git a/Infrastructure/Logging/Serilog/SeqLoggerModel.cs b/Infrastructure/Logging/Serilog/SeqLoggerModel.cs new file mode 100644 index 0000000..a643c5c --- /dev/null +++ b/Infrastructure/Logging/Serilog/SeqLoggerModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class SeqLoggerModel +{ + public bool IsUsed { get; set; } + public string ServerUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/Logging/Serilog/SerilogBuilder.cs b/Infrastructure/Logging/Serilog/SerilogBuilder.cs new file mode 100644 index 0000000..0433939 --- /dev/null +++ b/Infrastructure/Logging/Serilog/SerilogBuilder.cs @@ -0,0 +1,81 @@ +using Serilog; +using Serilog.Enrichers.Sensitive; +using Serilog.Events; +using Serilog.Sinks.MSSqlServer; + +namespace Indotalent.Infrastructure.Logging.Serilog; + +public static class SerilogBuilder +{ + public static void AddSerilogBuilder(this WebApplicationBuilder builder) + { + var settings = builder.Configuration.GetSection("LoggerSettings").Get(); + + var loggerConfiguration = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .Enrich.WithSensitiveDataMasking(options => + { + options.MaskValue = "***MASKED***"; + + options.MaskProperties.Add(MaskProperty.WithDefaults("Password")); + options.MaskProperties.Add(MaskProperty.WithDefaults("Secret")); + options.MaskProperties.Add(MaskProperty.WithDefaults("Token")); + options.MaskProperties.Add(MaskProperty.WithDefaults("ApiKey")); + options.MaskProperties.Add(MaskProperty.WithDefaults("ConfirmPassword")); + + }) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"); + + if (settings?.File?.IsUsed == true) + { + var interval = settings.File.RollingInterval.ToLower() switch + { + "hour" => RollingInterval.Hour, + "month" => RollingInterval.Month, + _ => RollingInterval.Day + }; + + loggerConfiguration.WriteTo.File( + path: settings.File.Path, + rollingInterval: interval, + restrictedToMinimumLevel: LogEventLevel.Error, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"); + } + + if (settings?.Database?.IsUsed == true) + { + var columnOptions = new ColumnOptions(); + columnOptions.Store.Add(StandardColumn.LogEvent); + columnOptions.LogEvent.ColumnName = "LogEvent"; + var connectionString = builder.Configuration.GetSection("DatabaseSettings:MsSQL:ConnectionString").Value; + + if (!string.IsNullOrEmpty(connectionString)) + { + loggerConfiguration.WriteTo.MSSqlServer( + connectionString: connectionString, + sinkOptions: new MSSqlServerSinkOptions + { + TableName = settings.Database.TableName, + AutoCreateSqlTable = settings.Database.AutoCreateSqlTable + }, + columnOptions: columnOptions, + restrictedToMinimumLevel: LogEventLevel.Error); + } + } + + if (settings?.Seq?.IsUsed == true && !string.IsNullOrEmpty(settings.Seq.ServerUrl)) + { + // Send logs to the Seq server (Self-hosted dashboard by Datalust [https://datalust.co/]) + loggerConfiguration.WriteTo.Seq( + serverUrl: settings.Seq.ServerUrl, + restrictedToMinimumLevel: LogEventLevel.Information + ); + } + + Log.Logger = loggerConfiguration.CreateLogger(); + builder.Host.UseSerilog(); + } +} diff --git a/Infrastructure/OData/DI.cs b/Infrastructure/OData/DI.cs new file mode 100644 index 0000000..0146f8f --- /dev/null +++ b/Infrastructure/OData/DI.cs @@ -0,0 +1,35 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.AspNetCore.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Indotalent.Infrastructure.OData; + +public static class DI +{ + public static IServiceCollection AddODataService(this IServiceCollection services) + { + services.AddControllers().AddOData(options => + { + options.AddRouteComponents("odata", GetEdmModel()) + .Select() + .Filter() + .OrderBy() + .Expand() + .Count() + .SetMaxTop(GlobalConsts.ODataMaxTop); + }); + + return services; + } + + private static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + + builder.EntitySet("SerilogLogs"); + + return builder.GetEdmModel(); + } +} \ No newline at end of file diff --git a/Infrastructure/OData/ODataResponse.cs b/Infrastructure/OData/ODataResponse.cs new file mode 100644 index 0000000..6cec61c --- /dev/null +++ b/Infrastructure/OData/ODataResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Indotalent.Infrastructure.OData; + +public class ODataResponse +{ + [JsonPropertyName("value")] + public List Value { get; set; } = new(); + + [JsonPropertyName("@odata.count")] + public int Count { get; set; } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..bf3cd89 --- /dev/null +++ b/Program.cs @@ -0,0 +1,127 @@ +using Indotalent.ConfigBackEnd; +using Indotalent.ConfigBackEnd.Middleware; +using Indotalent.ConfigFrontEnd; +using Indotalent.ConfigFrontEnd.Filter; +using Indotalent.Data.Entities; +using Indotalent.Features; +using Indotalent.Features.Account; +using Indotalent.Features.Root; +using Indotalent.Infrastructure; +using Indotalent.Infrastructure.Database; +using Indotalent.Infrastructure.Logging.Serilog; +using Microsoft.OpenApi; +using MudBlazor.Services; +using QuestPDF.Infrastructure; +using Serilog; + + + +//private and non profit. commercial less than 1 million USD +//ref: https://www.questpdf.com/license/ +QuestPDF.Settings.License = LicenseType.Community; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddSerilogBuilder(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddConfigBackEndDI(); + +builder.Services.AddInfrastructureDI(builder.Configuration); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = null; //null => for unlimited + }); + +builder.Services.AddMudServices(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); + options.CustomSchemaIds(type => type.FullName); +}); + +builder.Services.AddConfigFrontEndDI(); +builder.Services.AddFeaturesDI(); + +builder.Services.AddRazorPages(options => +{ + options.RootDirectory = "/Features"; +}); + +var app = builder.Build(); + +app.MapRazorPages(); + +app.UseMiddleware(); + +app.MapGroup("/api/account").WithTags("Account").MapIdentityApi(); + +using (var scope = app.Services.CreateScope()) +{ + var settings = app.Configuration.GetSection("DatabaseSettings").Get(); + if (settings?.MsSQL?.IsUsed == true || + settings?.PostgreSQL?.IsUsed == true || + settings?.MySQL?.IsUsed == true) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + + await DatabaseSeeder.SeedAsync(scope.ServiceProvider); + } +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.MapSwagger().RequireAuthorization(); + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseStaticFiles(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapControllers(); + +var apiGroup = app.MapGroup("/api").AddEndpointFilter(); + +apiGroup.MapAccountEndpoints(); +apiGroup.MapFeaturesEndpoint(); + +try +{ + Log.Information("Starting Web Application..."); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..e977eef --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,15 @@ + + + + + false + false + true + Release + Any CPU + FileSystem + bin\Release\net10.0\publish\ + FileSystem + <_TargetId>Folder + + \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile1.pubxml b/Properties/PublishProfiles/FolderProfile1.pubxml new file mode 100644 index 0000000..0ad629f --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile1.pubxml @@ -0,0 +1,19 @@ + + + + + true + false + true + Release + Any CPU + FileSystem + bin\Release\net10.0\publish\ + FileSystem + <_TargetId>Folder + + net10.0 + 8ef814d3-27d3-2851-8c79-c47eeaab2a19 + false + + \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..ab08469 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/Shared/Consts/GlobalConsts.cs b/Shared/Consts/GlobalConsts.cs new file mode 100644 index 0000000..0bda0db --- /dev/null +++ b/Shared/Consts/GlobalConsts.cs @@ -0,0 +1,40 @@ +namespace Indotalent.Shared.Consts; + +public static class GlobalConsts +{ + public const string AppInitial = "OMS"; + public const string AppName = "OMS: Order Management System"; + public const string AppTagline = "Streamlined Order Cycle: From Purchase to Sales."; + + public const int StringLengthId = 36; + public const int StringLengthShort = 500; + public const int StringLengthMedium = 1000; + public const int StringLengthLong = 4000; + + + public const int ODataMaxTop = 5000; + + + public const string BehaviourError = nameof(BehaviourError); + public const string GlobalError = nameof(GlobalError); + + public static class Roles + { + public const string Administrator = "Administrator"; + public const string Manager = "Manager"; + public const string Employee = "Employee"; + } + + public static class Formatting + { + public const string DateFormat = "dd MMM yyyy"; + public const string DateTimeFormat = "dd MMM yyyy HH:mm"; + public const string CurrencyCulture = "id-ID"; + } + + public static class Pagination + { + public const int DefaultPageSize = 10; + public static readonly int[] PageSizeOptions = { 10, 25, 50, 100 }; + } +} \ No newline at end of file diff --git a/Shared/Models/ApiRequest.cs b/Shared/Models/ApiRequest.cs new file mode 100644 index 0000000..ca9a006 --- /dev/null +++ b/Shared/Models/ApiRequest.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Shared.Models; + +public class ApiRequest +{ + public int Skip { get; set; } = 1; + public int Top { get; set; } = 5; + public string? SearchTerm { get; set; } + public string? SortBy { get; set; } + public bool IsAscending { get; set; } = true; + public bool IsPagedEnabled { get; set; } = true; +} \ No newline at end of file diff --git a/Shared/Models/ApiResponse.cs b/Shared/Models/ApiResponse.cs new file mode 100644 index 0000000..891bd40 --- /dev/null +++ b/Shared/Models/ApiResponse.cs @@ -0,0 +1,22 @@ +namespace Indotalent.Shared.Models; + +public class ApiResponse +{ + public bool IsSuccess { get; set; } + public int StatusCode { get; set; } + public string? Message { get; set; } + public T? Value { get; set; } + public PaginationMetadata? Pagination { get; set; } + public List? Errors { get; set; } + public DateTime ServerTime { get; set; } = DateTime.UtcNow; +} + +public class PaginationMetadata +{ + public int Count { get; set; } + public int Top { get; set; } + public int Skip { get; set; } + public int TotalPages => (int)Math.Ceiling((double)Count / (Top == 0 ? 1 : Top)); + public bool HasPrevious => Skip > 0; + public bool HasNext => (Skip + Top) < Count; +} \ No newline at end of file diff --git a/Shared/Models/PagedList.cs b/Shared/Models/PagedList.cs new file mode 100644 index 0000000..1243a3e --- /dev/null +++ b/Shared/Models/PagedList.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Indotalent.Shared.Models; + +public class PagedList +{ + public PagedList() + { + Value = new List(); + } + + public List Value { get; set; } + public int Skip { get; set; } + public int Top { get; set; } + public int TotalPages { get; set; } + public int Count { get; set; } + + [JsonConstructor] + public PagedList(List value, int count, int skip, int top, int totalPages) + { + Value = value; + Count = count; + Skip = skip; + Top = top; + TotalPages = totalPages; + } + + public PagedList(List value, int count, int skip, int top) + { + Value = value; + Count = count; + Skip = skip; + Top = top; + TotalPages = (int)Math.Ceiling(count / (double)top); + } + + public bool HasPrevious => Skip > 0; + public bool HasNext => (Skip + Top) < Count; +} \ No newline at end of file diff --git a/Shared/Utils/FluentValidationHelper.cs b/Shared/Utils/FluentValidationHelper.cs new file mode 100644 index 0000000..8bb9fb1 --- /dev/null +++ b/Shared/Utils/FluentValidationHelper.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Indotalent.Shared.Utils; + +public static class FluentValidationHelper +{ + public static Func>> ValidateValue(this AbstractValidator validator) => async (model, propertyName) => + { + if (model is not T typedModel) + return Array.Empty(); + + var context = ValidationContext.CreateWithOptions(typedModel, x => x.IncludeProperties(propertyName)); + var result = await validator.ValidateAsync(context); + + if (result.IsValid) + return Array.Empty(); + + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/Shared/Utils/InventoryTransactionHelper.cs b/Shared/Utils/InventoryTransactionHelper.cs new file mode 100644 index 0000000..268ef7e --- /dev/null +++ b/Shared/Utils/InventoryTransactionHelper.cs @@ -0,0 +1,77 @@ +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Shared.Utils; + +public static class InventoryTransactionHelper +{ + public static void CalculateInvenTrans(AppDbContext context, InventoryTransaction transaction) + { + if (transaction == null) + { + throw new Exception("Inventory transaction is null"); + } + + var moduleName = transaction.ModuleName; + + if (moduleName != "StockCount" && (transaction.Movement ?? 0.0) <= 0.0) + { + throw new Exception("Quantity must not zero and should be positive."); + } + + if (moduleName == "StockCount" && (transaction.QtySCCount ?? 0.0) <= 0.0) + { + throw new Exception("Quantity must not zero and should be positive."); + } + + switch (moduleName) + { + case "DeliveryOrder": + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "CUSTOMER"); + break; + + case "GoodsReceive": + transaction.TransType = InventoryTransType.In; + transaction.WarehouseFromId = GetSystemWarehouseId(context, "VENDOR"); + transaction.WarehouseToId = transaction.WarehouseId; + break; + + case "SalesReturn": + transaction.TransType = InventoryTransType.In; + transaction.WarehouseFromId = GetSystemWarehouseId(context, "VENDOR"); + transaction.WarehouseToId = transaction.WarehouseId; + break; + + case "PurchaseReturn": + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "CUSTOMER"); + break; + } + + transaction.Stock = (transaction.Movement ?? 0.0) * (int)(transaction.TransType ?? InventoryTransType.In); + } + + public static double GetStock(AppDbContext context, string? warehouseId, string? productId, string? currentId = null) + { + return context.InventoryTransaction + .Where(x => + x.Status == InventoryTransactionStatus.Confirmed && + x.WarehouseId == warehouseId && + x.ProductId == productId && + x.Id != currentId) + .Sum(x => x.Stock ?? 0.0); + } + + private static string? GetSystemWarehouseId(AppDbContext context, string name) + { + return context.Warehouse + .Where(x => x.SystemWarehouse == true && x.Name == name) + .Select(x => x.Id) + .FirstOrDefault(); + } +} \ No newline at end of file diff --git a/Shared/Utils/PurchaseOrderHelper.cs b/Shared/Utils/PurchaseOrderHelper.cs new file mode 100644 index 0000000..89988c3 --- /dev/null +++ b/Shared/Utils/PurchaseOrderHelper.cs @@ -0,0 +1,32 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Shared.Utils; + +public static class PurchaseOrderHelper +{ + public static void Recalculate(AppDbContext context, string purchaseOrderId) + { + var purchaseOrder = context.PurchaseOrder + .Include(x => x.Tax) + .SingleOrDefault(x => x.Id == purchaseOrderId); + + if (purchaseOrder == null) + return; + + var purchaseOrderItems = context.PurchaseOrderItem + .Where(x => x.PurchaseOrderId == purchaseOrderId) + .ToList(); + + purchaseOrder.BeforeTaxAmount = purchaseOrderItems.Sum(x => (x.UnitPrice ?? 0) * (decimal)(x.Quantity ?? 0)); + + var taxPercentage = purchaseOrder.Tax?.PercentageValue ?? 0; + purchaseOrder.TaxAmount = (purchaseOrder.BeforeTaxAmount ?? 0) * taxPercentage / 100; + + purchaseOrder.AfterTaxAmount = (purchaseOrder.BeforeTaxAmount ?? 0) + (purchaseOrder.TaxAmount ?? 0); + + context.PurchaseOrder.Update(purchaseOrder); + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/Shared/Utils/SalesOrderHelper.cs b/Shared/Utils/SalesOrderHelper.cs new file mode 100644 index 0000000..c62ea44 --- /dev/null +++ b/Shared/Utils/SalesOrderHelper.cs @@ -0,0 +1,32 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Shared.Utils; + +public static class SalesOrderHelper +{ + public static void Recalculate(AppDbContext context, string salesOrderId) + { + var salesOrder = context.SalesOrder + .Include(x => x.Tax) + .SingleOrDefault(x => x.Id == salesOrderId); + + if (salesOrder == null) + return; + + var salesOrderItems = context.SalesOrderItem + .Where(x => x.SalesOrderId == salesOrderId) + .ToList(); + + salesOrder.BeforeTaxAmount = salesOrderItems.Sum(x => x.Total ?? 0m); + + var taxPercentage = salesOrder.Tax?.PercentageValue ?? 0; + salesOrder.TaxAmount = (salesOrder.BeforeTaxAmount ?? 0m) * (decimal)taxPercentage / 100m; + + salesOrder.AfterTaxAmount = (salesOrder.BeforeTaxAmount ?? 0m) + (salesOrder.TaxAmount ?? 0m); + + context.SalesOrder.Update(salesOrder); + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/Shared/Utils/SequentialGuidGenerator.cs b/Shared/Utils/SequentialGuidGenerator.cs new file mode 100644 index 0000000..9a74d3a --- /dev/null +++ b/Shared/Utils/SequentialGuidGenerator.cs @@ -0,0 +1,25 @@ +namespace Indotalent.Shared.Utils; + +public static class SequentialGuidGenerator +{ + public static string NewSequentialId() + { + byte[] guidBytes = Guid.NewGuid().ToByteArray(); + + byte[] timestampBytes = BitConverter.GetBytes(DateTime.UtcNow.Ticks); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timestampBytes); + } + + guidBytes[10] = timestampBytes[2]; + guidBytes[11] = timestampBytes[3]; + guidBytes[12] = timestampBytes[4]; + guidBytes[13] = timestampBytes[5]; + guidBytes[14] = timestampBytes[6]; + guidBytes[15] = timestampBytes[7]; + + return new Guid(guidBytes).ToString(); + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..518be13 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,113 @@ +{ + "IdentitySettings": { + "Password": { + "RequireDigit": false, + "RequiredLength": 4, + "RequireNonAlphanumeric": false, + "RequireUppercase": false, + "RequireLowercase": false, + "RequiredUniqueChars": 0 + }, + "Cookies": { + "Name": ".AspNetCore.Identity", + "LoginPath": "/account/login", + "LogoutPath": "/account/logout", + "AccessDeniedPath": "/account/access-denied", + "ExpireDays": 7 + }, + "DefaultAdmin": { + "FullName": "Super Admin", + "Email": "admin@root.com", + "Password": "123456" + }, + "SignIn": { + "RequireConfirmedAccount": true + }, + "SsoFirebase": { + "IsUsed": true, + "OpenForPublic": true, + "ProjectId": "sso-firebase-ea811", + "ApiKey": "AIzaSyCr4ihZDDi8Sx_4gj4Qefu5QhygEZy9oXQ", + "AuthDomain": "sso-firebase-ea811.firebaseapp.com", + "StorageBucket": "sso-firebase-ea811.firebasestorage.app", + "MessagingSenderId": "667215413835", + "AppId": "1:667215413835:web:38d20ecedd582d008e43ce" + }, + "SsoKeycloak": { + "IsUsed": false, + "Authority": "https://keycloak.yourdomain.com/realms/master", + "ClientId": "soltemp-client-frontend", + "ClientSecret": "your-client-secret-key", + "RequireHttpsMetadata": true + } + }, + "JwtSettings": { + "Key": "PRAGMATIC_FIX_KEY_MIN_32_CHARS_LONG_2026", + "Issuer": "soltemp_Issuer", + "Audience": "soltemp_Users", + "DurationInMinutes": 1440 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Hangfire": "Information" + } + }, + "AllowedHosts": "*", + "DatabaseSettings": { + "MsSQL": { + "IsUsed": true, + "ConnectionString": "Server=mssql,1433;Database=Blazor-OMS;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True;", + "TimeoutInSeconds": 1800 + }, + "MySQL": { + "IsUsed": false, + "ConnectionString": "", + "TimeoutInSeconds": 1800 + }, + "PostgreSQL": { + "IsUsed": false, + "ConnectionString": "", + "TimeoutInSeconds": 1800 + } + }, + "BackgroundJobSettings": { + "Hangfire": { + "IsUsed": true, + "ConnectionString": "Server=mssql,1433;Database=Blazor-OMS-Hangfire;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True;", + "DashboardPath": "/hangfire" + }, + "Quartz": { + "IsUsed": false, + "TablePrefix": "QRTZ_" + } + }, + "EmailSettings": { + "SendGrid": { "IsUsed": false, "ApiKey": "", "FromEmail": "" }, + "Mailgun": { "IsUsed": false, "ApiKey": "", "Domain": "", "FromEmail": "" }, + "Mailjet": { "IsUsed": false, "ApiKey": "", "ApiSecret": "", "FromEmail": "" }, + "Smtp": { + "IsUsed": true, + "Host": "smtp.gmail.com", + "Port": 465, + "UserName": "go2ismail@gmail.com", + "Password": "lrbi cooi gcnt oquw", + "FromAddress": "go2ismail@gmail.com", + "FromName": "no-reply" + } + }, + "FileStorageSettings": { + "Avatar": { "IsUsed": true, "StoragePath": "avatars", "BaseUrl": "https://oms.indotalent.com/avatars" }, + "Local": { "IsUsed": true, "StoragePath": "uploads", "BaseUrl": "https://oms.indotalent.com/uploads" }, + "AwsS3": { "IsUsed": false, "AccessKey": "", "SecretKey": "", "BucketName": "" }, + "AzureBlob": { "IsUsed": false, "ConnectionString": "", "ContainerName": "" }, + "GoogleCloud": { "IsUsed": false, "ProjectId": "", "BucketName": "" }, + "Dropbox": { "IsUsed": false, "AccessToken": "" } + }, + "LoggerSettings": { + "File": { "IsUsed": true, "Path": "wwwroot/xlogs/app-log-.txt", "RollingInterval": "Day" }, + "Database": { "IsUsed": true, "TableName": "SerilogLogs", "AutoCreateSqlTable": false }, + "Seq": { "IsUsed": false, "ServerUrl": "" } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..482dcd0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# ===================================================================== +# Blazor OMS - Docker Compose untuk Portainer Stack +# ===================================================================== +# ASP.NET Core Blazor .NET 10 dengan database Blazor-OMS di MS SQL +# Routing via Nginx Proxy Manager (container name) +# ===================================================================== + +services: + blazor-oms: + build: + context: . + dockerfile: Dockerfile + container_name: blazor-oms + restart: unless-stopped + expose: + - 8080 + networks: + - indotalent-network + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + # MS SQL - database Blazor-OMS + - DatabaseSettings__MsSQL__ConnectionString=Server=mssql,1433;Database=Blazor-OMS;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + # Hangfire background job (pakai MS SQL juga) + - BackgroundJobSettings__Hangfire__ConnectionString=Server=mssql,1433;Database=Blazor-OMS-Hangfire;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + # Email SMTP (tetap) + - EmailSettings__Smtp__Host=smtp.gmail.com + - EmailSettings__Smtp__Port=465 + - EmailSettings__Smtp__UserName=go2ismail@gmail.com + - EmailSettings__Smtp__Password=lrbi cooi gcnt oquw + - EmailSettings__Smtp__FromAddress=go2ismail@gmail.com + # ================================================================= + # RESOURCE LIMITS + # ================================================================= + mem_limit: 512m # Max 512 MB RAM + mem_reservation: 256m # Minimal 256 MB RAM + cpus: '0.5' # Max 50% dari 1 CPU core + labels: + - "indotalent.service=blazor-oms" + - "indotalent.description=Blazor OMS - Order Management System" + +networks: + indotalent-network: + external: true + name: indotalent-network \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..807729e --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/wwwroot/avatars/readme.txt b/wwwroot/avatars/readme.txt new file mode 100644 index 0000000..d587dd6 --- /dev/null +++ b/wwwroot/avatars/readme.txt @@ -0,0 +1 @@ +avatars file location (confirm with FileStorageSettings:avatar at appsettings.json). make sure IIS have write access to this folder. \ No newline at end of file diff --git a/wwwroot/favico.png b/wwwroot/favico.png new file mode 100644 index 0000000..1b66258 Binary files /dev/null and b/wwwroot/favico.png differ