commit c40792266ad8f2602aed844e3557008bf46f3478 Author: Ismail Hamzah Date: Tue Jul 21 13:59: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/Budget.cs b/Data/Entities/Budget.cs new file mode 100644 index 0000000..95b796f --- /dev/null +++ b/Data/Entities/Budget.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Budget : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } + public BudgetStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public Campaign? Campaign { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Campaign.cs b/Data/Entities/Campaign.cs new file mode 100644 index 0000000..e27935c --- /dev/null +++ b/Data/Entities/Campaign.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Campaign : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public decimal? TargetRevenueAmount { get; set; } + public DateTime? CampaignDateStart { get; set; } + public DateTime? CampaignDateFinish { get; set; } + public CampaignStatus Status { get; set; } + public string? SalesTeamId { get; set; } + public SalesTeam? SalesTeam { get; set; } + public ICollection BudgetList { get; set; } = new List(); + public ICollection ExpenseList { get; set; } = new List(); + public ICollection LeadList { get; set; } = new List(); +} \ 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/CreditNote.cs b/Data/Entities/CreditNote.cs new file mode 100644 index 0000000..69dcd8d --- /dev/null +++ b/Data/Entities/CreditNote.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class CreditNote : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? CreditNoteDate { get; set; } + public CreditNoteStatus CreditNoteStatus { get; set; } + public string? Description { get; set; } + public string? SalesReturnId { get; set; } + public SalesReturn? SalesReturn { get; set; } +} \ No newline at end of file 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/DebitNote.cs b/Data/Entities/DebitNote.cs new file mode 100644 index 0000000..be2e726 --- /dev/null +++ b/Data/Entities/DebitNote.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class DebitNote : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? DebitNoteDate { get; set; } + public DebitNoteStatus DebitNoteStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseReturnId { get; set; } + public PurchaseReturn? PurchaseReturn { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/DeliveryOrder.cs b/Data/Entities/DeliveryOrder.cs new file mode 100644 index 0000000..b71b856 --- /dev/null +++ b/Data/Entities/DeliveryOrder.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class DeliveryOrder : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? DeliveryDate { get; set; } + public DeliveryOrderStatus Status { 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/Expense.cs b/Data/Entities/Expense.cs new file mode 100644 index 0000000..d29327d --- /dev/null +++ b/Data/Entities/Expense.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Expense : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } + public ExpenseStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public Campaign? Campaign { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/GoodsReceive.cs b/Data/Entities/GoodsReceive.cs new file mode 100644 index 0000000..b923029 --- /dev/null +++ b/Data/Entities/GoodsReceive.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class GoodsReceive : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? ReceiveDate { get; set; } + public GoodsReceiveStatus Status { 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/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/Lead.cs b/Data/Entities/Lead.cs new file mode 100644 index 0000000..5df688b --- /dev/null +++ b/Data/Entities/Lead.cs @@ -0,0 +1,46 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Lead : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? CompanyName { get; set; } + public string? CompanyDescription { get; set; } + public string? CompanyAddressStreet { get; set; } + public string? CompanyAddressCity { get; set; } + public string? CompanyAddressState { get; set; } + public string? CompanyAddressZipCode { get; set; } + public string? CompanyAddressCountry { get; set; } + public string? CompanyPhoneNumber { get; set; } + public string? CompanyFaxNumber { get; set; } + public string? CompanyEmail { get; set; } + public string? CompanyWebsite { get; set; } + public string? CompanyWhatsApp { get; set; } + public string? CompanyLinkedIn { get; set; } + public string? CompanyFacebook { get; set; } + public string? CompanyInstagram { get; set; } + public string? CompanyTwitter { get; set; } + public DateTime? DateProspecting { get; set; } + public DateTime? DateClosingEstimation { get; set; } + public DateTime? DateClosingActual { get; set; } + public decimal? AmountTargeted { get; set; } + public decimal? AmountClosed { get; set; } + public decimal? BudgetScore { get; set; } + public decimal? AuthorityScore { get; set; } + public decimal? NeedScore { get; set; } + public decimal? TimelineScore { get; set; } + public PipelineStage PipelineStage { get; set; } + public ClosingStatus ClosingStatus { get; set; } + public string? ClosingNote { get; set; } + public string? CampaignId { get; set; } + public Campaign? Campaign { get; set; } + public string? SalesTeamId { get; set; } + public SalesTeam? SalesTeam { get; set; } + public ICollection LeadContacts { get; set; } = new List(); + public ICollection LeadActivities { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/LeadActivity.cs b/Data/Entities/LeadActivity.cs new file mode 100644 index 0000000..7976cdf --- /dev/null +++ b/Data/Entities/LeadActivity.cs @@ -0,0 +1,18 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class LeadActivity : BaseEntity, IHasAutoNumber +{ + public string? LeadId { get; set; } + public Lead? Lead { get; set; } + public string? AutoNumber { get; set; } + public string? Summary { get; set; } + public string? Description { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public LeadActivityType Type { get; set; } + public string? AttachmentName { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/LeadContact.cs b/Data/Entities/LeadContact.cs new file mode 100644 index 0000000..43a445e --- /dev/null +++ b/Data/Entities/LeadContact.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class LeadContact : BaseEntity, IHasAutoNumber +{ + public string? LeadId { get; set; } + public Lead? Lead { get; set; } + public string? AutoNumber { get; set; } + public string? FullName { get; set; } + public string? Description { get; set; } + public string? AddressStreet { get; set; } + public string? AddressCity { get; set; } + public string? AddressState { get; set; } + public string? AddressZipCode { get; set; } + public string? AddressCountry { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? MobileNumber { get; set; } + public string? Email { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Twitter { get; set; } + public string? Instagram { get; set; } + public string? AvatarName { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/NegativeAdjustment.cs b/Data/Entities/NegativeAdjustment.cs new file mode 100644 index 0000000..7ef05cb --- /dev/null +++ b/Data/Entities/NegativeAdjustment.cs @@ -0,0 +1,13 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class NegativeAdjustment : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public AdjustmentStatus Status { get; set; } + public string? Description { 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/PositiveAdjustment.cs b/Data/Entities/PositiveAdjustment.cs new file mode 100644 index 0000000..f7d15d8 --- /dev/null +++ b/Data/Entities/PositiveAdjustment.cs @@ -0,0 +1,13 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class PositiveAdjustment : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public AdjustmentStatus Status { get; set; } + public string? Description { 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/PurchaseRequisition.cs b/Data/Entities/PurchaseRequisition.cs new file mode 100644 index 0000000..bacbc37 --- /dev/null +++ b/Data/Entities/PurchaseRequisition.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 PurchaseRequisition : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? RequisitionDate { get; set; } + public PurchaseRequisitionStatus RequisitionStatus { 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 PurchaseRequisitionItemList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/PurchaseRequisitionItem.cs b/Data/Entities/PurchaseRequisitionItem.cs new file mode 100644 index 0000000..57b7733 --- /dev/null +++ b/Data/Entities/PurchaseRequisitionItem.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PurchaseRequisitionItem : BaseEntity +{ + public string? PurchaseRequisitionId { get; set; } + public PurchaseRequisition? PurchaseRequisition { 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/PurchaseReturn.cs b/Data/Entities/PurchaseReturn.cs new file mode 100644 index 0000000..14ff66f --- /dev/null +++ b/Data/Entities/PurchaseReturn.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class PurchaseReturn : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public PurchaseReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? GoodsReceiveId { get; set; } + public GoodsReceive? GoodsReceive { get; set; } +} \ 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/SalesQuotation.cs b/Data/Entities/SalesQuotation.cs new file mode 100644 index 0000000..15d3272 --- /dev/null +++ b/Data/Entities/SalesQuotation.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class SalesQuotation : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? QuotationDate { get; set; } + public SalesQuotationStatus QuotationStatus { 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 SalesQuotationItemList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/SalesQuotationItem.cs b/Data/Entities/SalesQuotationItem.cs new file mode 100644 index 0000000..cadcb8b --- /dev/null +++ b/Data/Entities/SalesQuotationItem.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class SalesQuotationItem : BaseEntity +{ + public string? SalesQuotationId { get; set; } + public SalesQuotation? SalesQuotation { 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/SalesRepresentative.cs b/Data/Entities/SalesRepresentative.cs new file mode 100644 index 0000000..8031b78 --- /dev/null +++ b/Data/Entities/SalesRepresentative.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class SalesRepresentative : BaseEntity, IHasAutoNumber +{ + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? SalesTeamId { get; set; } + public SalesTeam? SalesTeam { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/SalesReturn.cs b/Data/Entities/SalesReturn.cs new file mode 100644 index 0000000..7194794 --- /dev/null +++ b/Data/Entities/SalesReturn.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class SalesReturn : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public SalesReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? DeliveryOrderId { get; set; } + public DeliveryOrder? DeliveryOrder { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/SalesTeam.cs b/Data/Entities/SalesTeam.cs new file mode 100644 index 0000000..0520484 --- /dev/null +++ b/Data/Entities/SalesTeam.cs @@ -0,0 +1,11 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class SalesTeam : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } + public ICollection SalesRepresentativeList { get; set; } = new List(); +} \ No newline at end of file diff --git a/Data/Entities/Scrapping.cs b/Data/Entities/Scrapping.cs new file mode 100644 index 0000000..02d9771 --- /dev/null +++ b/Data/Entities/Scrapping.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class Scrapping : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? ScrappingDate { get; set; } + public ScrappingStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } +} \ 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/StockCount.cs b/Data/Entities/StockCount.cs new file mode 100644 index 0000000..6648d91 --- /dev/null +++ b/Data/Entities/StockCount.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class StockCount : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? CountDate { get; set; } + public StockCountStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public Warehouse? Warehouse { 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/TransferIn.cs b/Data/Entities/TransferIn.cs new file mode 100644 index 0000000..0e18d85 --- /dev/null +++ b/Data/Entities/TransferIn.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class TransferIn : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? TransferReceiveDate { get; set; } + public TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? TransferOutId { get; set; } + public TransferOut? TransferOut { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/TransferOut.cs b/Data/Entities/TransferOut.cs new file mode 100644 index 0000000..22352a9 --- /dev/null +++ b/Data/Entities/TransferOut.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Data.Enums; + +namespace Indotalent.Data.Entities; + +public class TransferOut : BaseEntity, IHasAutoNumber +{ + public string? AutoNumber { get; set; } + public DateTime? TransferReleaseDate { get; set; } + public TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseFromId { get; set; } + public Warehouse? WarehouseFrom { get; set; } + public string? WarehouseToId { get; set; } + public Warehouse? WarehouseTo { 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/AdjustmentStatus.cs b/Data/Enums/AdjustmentStatus.cs new file mode 100644 index 0000000..3ae4aa5 --- /dev/null +++ b/Data/Enums/AdjustmentStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum AdjustmentStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} 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/BudgetStatus.cs b/Data/Enums/BudgetStatus.cs new file mode 100644 index 0000000..0b3dfc7 --- /dev/null +++ b/Data/Enums/BudgetStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum BudgetStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/CampaignStatus.cs b/Data/Enums/CampaignStatus.cs new file mode 100644 index 0000000..b920c2a --- /dev/null +++ b/Data/Enums/CampaignStatus.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum CampaignStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("OnProgress")] + OnProgress = 3, + [Description("OnHold")] + OnHold = 4, + [Description("Finished")] + Finished = 5, + [Description("Archived")] + Archived = 6 +} diff --git a/Data/Enums/ClosingStatus.cs b/Data/Enums/ClosingStatus.cs new file mode 100644 index 0000000..d647932 --- /dev/null +++ b/Data/Enums/ClosingStatus.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum ClosingStatus +{ + [Description("ClosedLost")] + ClosedLost = 0, + [Description("ClosedWon")] + ClosedWon = 1, + [Description("OnProgress")] + OnProgress = 2 +} diff --git a/Data/Enums/CreditNoteStatus.cs b/Data/Enums/CreditNoteStatus.cs new file mode 100644 index 0000000..19edea7 --- /dev/null +++ b/Data/Enums/CreditNoteStatus.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + + +public enum CreditNoteStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/DebitNoteStatus.cs b/Data/Enums/DebitNoteStatus.cs new file mode 100644 index 0000000..6736f07 --- /dev/null +++ b/Data/Enums/DebitNoteStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum DebitNoteStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/DeliveryOrderStatus.cs b/Data/Enums/DeliveryOrderStatus.cs new file mode 100644 index 0000000..1c57ccd --- /dev/null +++ b/Data/Enums/DeliveryOrderStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum DeliveryOrderStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/ExpenseStatus.cs b/Data/Enums/ExpenseStatus.cs new file mode 100644 index 0000000..0755890 --- /dev/null +++ b/Data/Enums/ExpenseStatus.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum ExpenseStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} + diff --git a/Data/Enums/GoodsReceiveStatus.cs b/Data/Enums/GoodsReceiveStatus.cs new file mode 100644 index 0000000..f307f82 --- /dev/null +++ b/Data/Enums/GoodsReceiveStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum GoodsReceiveStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} 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/LeadActivityType.cs b/Data/Enums/LeadActivityType.cs new file mode 100644 index 0000000..a437869 --- /dev/null +++ b/Data/Enums/LeadActivityType.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum LeadActivityType +{ + [Description("Other")] + Other = 0, + [Description("Phone")] + Phone = 1, + [Description("Email")] + Email = 2, + [Description("Social Media")] + SocialMedia = 3, + [Description("Meeting")] + Meeting = 4, + [Description("Event")] + Event = 5 +} 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/PipelineStage.cs b/Data/Enums/PipelineStage.cs new file mode 100644 index 0000000..94c56ce --- /dev/null +++ b/Data/Enums/PipelineStage.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PipelineStage +{ + [Description("1. Prospecting")] + Prospecting = 0, + [Description("2. Qualification")] + Qualification = 1, + [Description("3. NeedAnalysis")] + NeedAnalysis = 2, + [Description("4. Proposal")] + Proposal = 3, + [Description("5. Negotiation")] + Negotiation = 4, + [Description("6. DecisionMaking")] + DecisionMaking = 5, + [Description("7. Closed")] + Closed = 6 +} 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/PurchaseRequisitionStatus.cs b/Data/Enums/PurchaseRequisitionStatus.cs new file mode 100644 index 0000000..a383ed9 --- /dev/null +++ b/Data/Enums/PurchaseRequisitionStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PurchaseRequisitionStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Ordered")] + Ordered = 3 +} diff --git a/Data/Enums/PurchaseReturnStatus.cs b/Data/Enums/PurchaseReturnStatus.cs new file mode 100644 index 0000000..4a13254 --- /dev/null +++ b/Data/Enums/PurchaseReturnStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum PurchaseReturnStatus +{ + [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/Enums/SalesQuotationStatus.cs b/Data/Enums/SalesQuotationStatus.cs new file mode 100644 index 0000000..bdb9517 --- /dev/null +++ b/Data/Enums/SalesQuotationStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum SalesQuotationStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Ordered")] + Ordered = 3 +} diff --git a/Data/Enums/SalesReturnStatus.cs b/Data/Enums/SalesReturnStatus.cs new file mode 100644 index 0000000..814587a --- /dev/null +++ b/Data/Enums/SalesReturnStatus.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum SalesReturnStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} + diff --git a/Data/Enums/ScrappingStatus.cs b/Data/Enums/ScrappingStatus.cs new file mode 100644 index 0000000..88ad5be --- /dev/null +++ b/Data/Enums/ScrappingStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum ScrappingStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/StockCountStatus.cs b/Data/Enums/StockCountStatus.cs new file mode 100644 index 0000000..0d59f22 --- /dev/null +++ b/Data/Enums/StockCountStatus.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum StockCountStatus +{ + [Description("Draft")] + Draft = 0, + [Description("Cancelled")] + Cancelled = 1, + [Description("Confirmed")] + Confirmed = 2, + [Description("Archived")] + Archived = 3 +} diff --git a/Data/Enums/TransferStatus.cs b/Data/Enums/TransferStatus.cs new file mode 100644 index 0000000..6a3cf7a --- /dev/null +++ b/Data/Enums/TransferStatus.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace Indotalent.Data.Enums; + +public enum TransferStatus +{ + [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..da80fdd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ===================================================================== +# Dockerfile untuk Blazor CRM (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 . +RUN dotnet restore + +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 --from=build /app . + +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:8080 + +EXPOSE 8080 + +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..6f7c7f1 --- /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..e1a144f --- /dev/null +++ b/Features/Account/AuthenticationLayout.razor @@ -0,0 +1,436 @@ +@using Indotalent.Shared.Consts +@inherits LayoutComponentBase +@inject NavigationManager Navigation + + + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+ @GlobalConsts.AppInitial + Blazor CRM Source Code +
+
+ + +

Your Complete
CRM Solution

+

A production-ready Blazor CRM source code that bridges your business and your customers. Sign in to explore the full application.

+ + +
+
+
+ + + +
+
+ Sales & Pipeline +

Quotation, orders, invoices & reporting

+
+
+
+
+ + + +
+
+ Purchase & Inventory +

Procurement, warehouse & stock control

+
+
+
+
+ + + +
+
+ Customer & Vendor +

Contacts, groups & relationship tracking

+
+
+
+
+ + + +
+
+ System Logs & Settings +

Full control with audit & configuration

+
+
+
+ + +
+
+ + + +
+
+ Production Ready + Enterprise-grade Blazor CRM source code +
+
+
+
+ + +
+
+ +
+
+ + + + + +
+ @GlobalConsts.AppInitial +
+ + + + + @Body + + + +
+
+ +
+ +@code { + private bool isDarkMode; + private MudTheme _theme = new() + { + PaletteLight = new PaletteLight() + { + Primary = "#6366F1", + Secondary = "#7c74fc", + AppbarBackground = "#6366F1" + } + }; + + 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..4445231 --- /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..d73150b --- /dev/null +++ b/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor @@ -0,0 +1,160 @@ +@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 + + + + + + + + Email Address + + + + @if (isProcessing) + { + + Sending link... + } + else + { + Send Reset Link + } + + + +
+ + Remember your password? + Back to Sign In + +
+ +@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..accf590 --- /dev/null +++ b/Features/Account/Login/Components/LoginPage.razor @@ -0,0 +1,413 @@ +@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 + + + + + + + + Email Address + + +
+ Password + Forgot password? +
+ + +
+ +
+ + + @if (isProcessing) + { + + Signing in... + } + else + { + Sign In + + + + + } + + + @if (IdentityOptions.Value.SsoFirebase.IsUsed) + { +
+ Or continue with +
+ + + Sign in with Google + + } +
+ +
+ + Don't have an account? + Create one + +
+ +@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..ce79773 --- /dev/null +++ b/Features/Account/Logout/LogoutPage.razor @@ -0,0 +1,163 @@ +@page "/account/logout" +@layout AuthenticationLayout + +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime + +Sign Out - @GlobalConsts.AppInitial + + + +
+ +
+
+ + + + + +
+
+ +

Sign Out

+

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

+ + + @if (isLoggingOut) + { + + Processing... + } + else + { + Sign Out + } + + + + 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..7ce2746 --- /dev/null +++ b/Features/Account/Register/Components/RegisterPage.razor @@ -0,0 +1,223 @@ +@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 + + + + + + + + Full Name + + + Email Address + + + Password + + + + @if (isProcessing) + { + + Creating account... + } + else + { + Create Account + } + + + +
+ + Already have an account? + Sign In + +
+ +@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..b3faedf --- /dev/null +++ b/Features/Account/ResetPassword/Components/ResetPasswordPage.razor @@ -0,0 +1,182 @@ +@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 + + + + + + + New Password + + + Confirm New Password + + + + @if (isProcessing) + { + + Resetting... + } + else + { + Reset Password + } + + + +@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..9e327fa --- /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..e3caf16 --- /dev/null +++ b/Features/FeaturesDI.cs @@ -0,0 +1,170 @@ +using Indotalent.Features.Inventory.MovementReport; +using Indotalent.Features.Inventory.NegativeAdjustment; +using Indotalent.Features.Inventory.PositiveAdjustment; +using Indotalent.Features.Inventory.Product; +using Indotalent.Features.Inventory.ProductGroup; +using Indotalent.Features.Inventory.Scrapping; +using Indotalent.Features.Inventory.StockCount; +using Indotalent.Features.Inventory.StockReport; +using Indotalent.Features.Inventory.TransactionReport; +using Indotalent.Features.Inventory.TransferIn; +using Indotalent.Features.Inventory.TransferOut; +using Indotalent.Features.Inventory.UnitMeasure; +using Indotalent.Features.Inventory.Warehouse; +using Indotalent.Features.Pipeline.Budget; +using Indotalent.Features.Pipeline.Campaign; +using Indotalent.Features.Pipeline.Expense; +using Indotalent.Features.Pipeline.Lead; +using Indotalent.Features.Pipeline.LeadActivity; +using Indotalent.Features.Pipeline.LeadContact; +using Indotalent.Features.Pipeline.SalesRepresentative; +using Indotalent.Features.Pipeline.SalesTeam; +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.DebitNote; +using Indotalent.Features.Purchase.GoodsReceive; +using Indotalent.Features.Purchase.PaymentDisburse; +using Indotalent.Features.Purchase.PaymentDisburseReport; +using Indotalent.Features.Purchase.PurchaseOrder; +using Indotalent.Features.Purchase.PurchaseReport; +using Indotalent.Features.Purchase.PurchaseRequisition; +using Indotalent.Features.Purchase.PurchaseReturn; +using Indotalent.Features.Purchase.PurchaseReturnReport; +using Indotalent.Features.Purchase.ReceiveReport; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Sales.CreditNote; +using Indotalent.Features.Sales.DeliveryOrder; +using Indotalent.Features.Sales.DeliveryReport; +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.SalesQuotation; +using Indotalent.Features.Sales.SalesReport; +using Indotalent.Features.Sales.SalesReturn; +using Indotalent.Features.Sales.SalesReturnReport; +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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + 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(); + + //pipeline + 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(); + 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(); + 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..fc7b533 --- /dev/null +++ b/Features/FeaturesEndpointMap.cs @@ -0,0 +1,168 @@ +using Indotalent.Features.Inventory.MovementReport; +using Indotalent.Features.Inventory.NegativeAdjustment; +using Indotalent.Features.Inventory.PositiveAdjustment; +using Indotalent.Features.Inventory.Product; +using Indotalent.Features.Inventory.ProductGroup; +using Indotalent.Features.Inventory.Scrapping; +using Indotalent.Features.Inventory.StockCount; +using Indotalent.Features.Inventory.StockReport; +using Indotalent.Features.Inventory.TransactionReport; +using Indotalent.Features.Inventory.TransferIn; +using Indotalent.Features.Inventory.TransferOut; +using Indotalent.Features.Inventory.UnitMeasure; +using Indotalent.Features.Inventory.Warehouse; +using Indotalent.Features.Pipeline.Budget; +using Indotalent.Features.Pipeline.Campaign; +using Indotalent.Features.Pipeline.Expense; +using Indotalent.Features.Pipeline.Lead; +using Indotalent.Features.Pipeline.LeadActivity; +using Indotalent.Features.Pipeline.LeadContact; +using Indotalent.Features.Pipeline.SalesRepresentative; +using Indotalent.Features.Pipeline.SalesTeam; +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.DebitNote; +using Indotalent.Features.Purchase.GoodsReceive; +using Indotalent.Features.Purchase.PaymentDisburse; +using Indotalent.Features.Purchase.PaymentDisburseReport; +using Indotalent.Features.Purchase.PurchaseOrder; +using Indotalent.Features.Purchase.PurchaseReport; +using Indotalent.Features.Purchase.PurchaseRequisition; +using Indotalent.Features.Purchase.PurchaseReturn; +using Indotalent.Features.Purchase.PurchaseReturnReport; +using Indotalent.Features.Purchase.ReceiveReport; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Sales.CreditNote; +using Indotalent.Features.Sales.DeliveryOrder; +using Indotalent.Features.Sales.DeliveryReport; +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.SalesQuotation; +using Indotalent.Features.Sales.SalesReport; +using Indotalent.Features.Sales.SalesReturn; +using Indotalent.Features.Sales.SalesReturnReport; +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(); + app.MapTransferOutEndpoints(); + app.MapTransferInEndpoints(); + app.MapPositiveAdjustmentEndpoints(); + app.MapNegativeAdjustmentEndpoints(); + app.MapStockCountEndpoints(); + app.MapScrappingEndpoints(); + app.MapTransactionReportEndpoints(); + app.MapStockReportEndpoints(); + app.MapMovementReportEndpoints(); + + //thirdparty + app.MapCustomerGroupEndpoints(); + app.MapCustomerCategoryEndpoints(); + app.MapCustomerEndpoints(); + app.MapCustomerContactEndpoints(); + app.MapVendorCategoryEndpoints(); + app.MapVendorGroupEndpoints(); + app.MapVendorEndpoints(); + app.MapVendorContactEndpoints(); + + //pipeline + app.MapSalesTeamEndpoints(); + app.MapSalesRepresentativeEndpoints(); + app.MapCampaignEndpoints(); + app.MapBudgetEndpoints(); + app.MapExpenseEndpoints(); + app.MapLeadEndpoints(); + app.MapLeadContactEndpoints(); + app.MapLeadActivityEndpoints(); + + //utilities + app.MapTodoEndpoints(); + app.MapBookingGroupEndpoints(); + app.MapBookingResourceEndpoints(); + app.MapBookingManagerEndpoints(); + app.MapBookingSchedulerEndpoints(); + app.MapProgramResourceEndpoints(); + app.MapProgramManagerEndpoints(); + + //purchase + app.MapPurchaseRequisitionEndpoints(); + app.MapPurchaseOrderEndpoints(); + app.MapGoodsReceiveEndpoints(); + app.MapPurchaseReturnEndpoints(); + app.MapPurchaseReportEndpoints(); + app.MapReceiveReportEndpoints(); + app.MapPurchaseReturnReportEndpoints(); + app.MapBillReportEndpoints(); + app.MapPaymentDisburseReportEndpoints(); + app.MapBillEndpoints(); + app.MapDebitNoteEndpoints(); + app.MapPaymentDisburseEndpoints(); + + //sales + app.MapSalesQuotationEndpoints(); + app.MapSalesOrderEndpoints(); + app.MapDeliveryOrderEndpoints(); + app.MapSalesReturnEndpoints(); + app.MapSalesReportEndpoints(); + app.MapDeliveryReportEndpoints(); + app.MapSalesReturnReportEndpoints(); + app.MapInvoiceReportEndpoints(); + app.MapPaymentReceiveReportEndpoints(); + app.MapInvoiceEndpoints(); + app.MapCreditNoteEndpoints(); + app.MapPaymentReceiveEndpoints(); + + return app; + } +} diff --git a/Features/Inventory/InventoryPage.razor b/Features/Inventory/InventoryPage.razor new file mode 100644 index 0000000..2f67c09 --- /dev/null +++ b/Features/Inventory/InventoryPage.razor @@ -0,0 +1,154 @@ +@page "/inventory" +@using Indotalent.Features.Inventory.MovementReport.Components +@using Indotalent.Features.Inventory.NegativeAdjustment.Components +@using Indotalent.Features.Inventory.PositiveAdjustment.Components +@using Indotalent.Features.Inventory.Product.Components +@using Indotalent.Features.Inventory.ProductGroup.Components +@using Indotalent.Features.Inventory.Scrapping.Components +@using Indotalent.Features.Inventory.StockCount.Components +@using Indotalent.Features.Inventory.StockReport.Components +@using Indotalent.Features.Inventory.TransactionReport.Components +@using Indotalent.Features.Inventory.TransferIn.Components +@using Indotalent.Features.Inventory.TransferOut.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" }, + { 4, "transfer-out" }, + { 5, "transfer-in" }, + { 6, "pos-adj" }, + { 7, "neg-adj" }, + { 8, "scrapping" }, + { 9, "stock-count" }, + { 10, "report-trans" }, + { 11, "report-stock" }, + { 12, "report-movement" } + }; + + 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/MovementReport/Components/MovementReportPage.razor b/Features/Inventory/MovementReport/Components/MovementReportPage.razor new file mode 100644 index 0000000..d55282e --- /dev/null +++ b/Features/Inventory/MovementReport/Components/MovementReportPage.razor @@ -0,0 +1,8 @@ +@page "/inventory/movement-report" +@using Indotalent.Features.Inventory.MovementReport.Components +@using MudBlazor + +<_MovementReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Inventory/MovementReport/Components/_MovementReportDataTable.razor b/Features/Inventory/MovementReport/Components/_MovementReportDataTable.razor new file mode 100644 index 0000000..4397e85 --- /dev/null +++ b/Features/Inventory/MovementReport/Components/_MovementReportDataTable.razor @@ -0,0 +1,298 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.MovementReport +@using Indotalent.Features.Inventory.MovementReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject MovementReportService MovementReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Inventory Movement Report + Pivot analysis of stock movements by transaction types. +
+
+ + / + Inventory + / + Movement Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + Warehouse + Product + ADJ+ + ADJ- + COUNT + DO + GR + PRN + SCRP + SRN + TO-IN + TO-OUT + TOTAL + + + @context.WarehouseName + + @context.ProductNumber + @context.ProductName + + @context.AdjPlus.ToString("N2") + @context.AdjMinus.ToString("N2") + @context.Count.ToString("N2") + @context.Do.ToString("N2") + @context.Gr.ToString("N2") + @context.Prn.ToString("N2") + @context.Scrp.ToString("N2") + @context.Srn.ToString("N2") + @context.ToIn.ToString("N2") + @context.ToOut.ToString("N2") + @context.GrandTotal.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 { + 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 MovementReportService.GetInventoryMovementReportListAsync(); + 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.WarehouseName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductNumber?.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("MovementReports"); + var currentRow = 1; + + string[] headers = { "Warehouse", "Number", "Product", "ADJ+", "ADJ-", "COUNT", "DO", "GR", "PRN", "SCRP", "SRN", "TO-IN", "TO-OUT", "GRAND TOTAL" }; + 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.WarehouseName; + worksheet.Cell(currentRow, 2).Value = item.ProductNumber; + worksheet.Cell(currentRow, 3).Value = item.ProductName; + worksheet.Cell(currentRow, 4).Value = item.AdjPlus; + worksheet.Cell(currentRow, 5).Value = item.AdjMinus; + worksheet.Cell(currentRow, 6).Value = item.Count; + worksheet.Cell(currentRow, 7).Value = item.Do; + worksheet.Cell(currentRow, 8).Value = item.Gr; + worksheet.Cell(currentRow, 9).Value = item.Prn; + worksheet.Cell(currentRow, 10).Value = item.Scrp; + worksheet.Cell(currentRow, 11).Value = item.Srn; + worksheet.Cell(currentRow, 12).Value = item.ToIn; + worksheet.Cell(currentRow, 13).Value = item.ToOut; + worksheet.Cell(currentRow, 14).Value = item.GrandTotal; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Movement_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/Inventory/MovementReport/Cqrs/GetInventoryMovementReportListHandler.cs b/Features/Inventory/MovementReport/Cqrs/GetInventoryMovementReportListHandler.cs new file mode 100644 index 0000000..37e6f77 --- /dev/null +++ b/Features/Inventory/MovementReport/Cqrs/GetInventoryMovementReportListHandler.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.MovementReport.Cqrs; + +public record GetInventoryMovementReportListDto +{ + public string? WarehouseName { get; init; } + public string? ProductNumber { get; init; } + public string? ProductName { get; init; } + public double AdjPlus { get; init; } + public double AdjMinus { get; init; } + public double Count { get; init; } + public double Do { get; init; } + public double Gr { get; init; } + public double Prn { get; init; } + public double Scrp { get; init; } + public double Srn { get; init; } + public double ToIn { get; init; } + public double ToOut { get; init; } + public double GrandTotal => AdjPlus + AdjMinus + Count + Do + Gr + Prn + Scrp + Srn + ToIn + ToOut; +} + +public record GetInventoryMovementReportListQuery() : IRequest>; + +public class GetInventoryMovementReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetInventoryMovementReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetInventoryMovementReportListQuery request, CancellationToken cancellationToken) + { + var transactions = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Warehouse) + .Include(x => x.Product) + .Where(x => + x.Product!.Physical == true && + x.Warehouse!.SystemWarehouse == false && + x.Status == Data.Enums.InventoryTransactionStatus.Confirmed + ) + .ToListAsync(cancellationToken); + + var results = transactions + .GroupBy(x => new { x.WarehouseId, x.ProductId }) + .Select(group => new GetInventoryMovementReportListDto + { + WarehouseName = group.Max(x => x.Warehouse?.Name), + ProductNumber = group.Max(x => x.Product?.AutoNumber), + ProductName = group.Max(x => x.Product?.Name), + AdjPlus = group.Where(x => x.ModuleCode == "ADJ+").Sum(x => x.Movement ?? 0), + AdjMinus = group.Where(x => x.ModuleCode == "ADJ-").Sum(x => x.Movement ?? 0), + Count = group.Where(x => x.ModuleCode == "COUNT").Sum(x => x.Movement ?? 0), + Do = group.Where(x => x.ModuleCode == "DO").Sum(x => x.Movement ?? 0), + Gr = group.Where(x => x.ModuleCode == "GR").Sum(x => x.Movement ?? 0), + Prn = group.Where(x => x.ModuleCode == "PRN").Sum(x => x.Movement ?? 0), + Scrp = group.Where(x => x.ModuleCode == "SCRP").Sum(x => x.Movement ?? 0), + Srn = group.Where(x => x.ModuleCode == "SRN").Sum(x => x.Movement ?? 0), + ToIn = group.Where(x => x.ModuleCode == "TO-IN").Sum(x => x.Movement ?? 0), + ToOut = group.Where(x => x.ModuleCode == "TO-OUT").Sum(x => x.Movement ?? 0) + }) + .OrderBy(x => x.WarehouseName) + .ThenBy(x => x.ProductName) + .ToList(); + + return results; + } +} \ No newline at end of file diff --git a/Features/Inventory/MovementReport/MovementReportEndpoint.cs b/Features/Inventory/MovementReport/MovementReportEndpoint.cs new file mode 100644 index 0000000..37cc1cd --- /dev/null +++ b/Features/Inventory/MovementReport/MovementReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.MovementReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.MovementReport; + +public static class MovementReportEndpoint +{ + public static void MapMovementReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/movement-report").WithTags("MovementReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInventoryMovementReportListQuery()); + return result.ToApiResponse("Movement report list retrieved successfully"); + }) + .WithName("GetInventoryMovementReportList"); + } +} \ No newline at end of file diff --git a/Features/Inventory/MovementReport/MovementReportService.cs b/Features/Inventory/MovementReport/MovementReportService.cs new file mode 100644 index 0000000..9bc5a6b --- /dev/null +++ b/Features/Inventory/MovementReport/MovementReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.MovementReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.MovementReport; + +public class MovementReportService : BaseService +{ + private readonly RestClient _client; + + public MovementReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetInventoryMovementReportListAsync() + { + var request = new RestRequest("api/movement-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Components/NegativeAdjustmentPage.razor b/Features/Inventory/NegativeAdjustment/Components/NegativeAdjustmentPage.razor new file mode 100644 index 0000000..48c5c89 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/NegativeAdjustmentPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/negative-adjustment" +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using Indotalent.Features.Inventory.NegativeAdjustment.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_NegativeAdjustmentCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_NegativeAdjustmentUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_NegativeAdjustmentDataTable 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 UpdateNegativeAdjustmentRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateNegativeAdjustmentRequest 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/NegativeAdjustment/Components/_NegativeAdjustmentCreateForm.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentCreateForm.razor new file mode 100644 index 0000000..ffab79b --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentCreateForm.razor @@ -0,0 +1,116 @@ +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject ISnackbar Snackbar + + +
+ +
+ Add Negative Adjustment + Record stock reduction from specific warehouses. +
+
+
+ + + + + Basic Information + + + Adjustment Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Adjustment + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateNegativeAdjustmentValidator _validator = new(); + private CreateNegativeAdjustmentRequest _model = new() { AdjustmentDate = DateTime.Today }; + private NegativeAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await NegativeAdjustmentService.GetNegativeAdjustmentLookupAsync(); + 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 NegativeAdjustmentService.CreateNegativeAdjustmentAsync(_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/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentDataTable.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentDataTable.razor new file mode 100644 index 0000000..13d78d9 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentDataTable.razor @@ -0,0 +1,377 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.NegativeAdjustment +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Negative Adjustment + Record stock reduction and inventory corrections. +
+
+ + / + Inventory + / + Negative Adjustment +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Negative Adjustment + + } +
+
+ + + + + + Number + + + Adjustment Date + + + Description + + + Status + + + + + + + @context.AutoNumber + @context.AdjustmentDate?.ToString("yyyy-MM-dd") + @context.Description + + @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 GetNegativeAdjustmentListResponse? _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 NegativeAdjustmentService.GetNegativeAdjustmentListAsync(); + 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.Description?.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("NegativeAdjustments"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Adjustment Date"; + worksheet.Cell(currentRow, 3).Value = "Description"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.AdjustmentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.Description; + worksheet.Cell(currentRow, 4).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", "NegativeAdjustment_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 NegativeAdjustmentService.GetNegativeAdjustmentByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateNegativeAdjustmentRequest + { + Id = detail.Id, + AdjustmentDate = detail.AdjustmentDate, + Status = detail.Status, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 NegativeAdjustmentService.DeleteNegativeAdjustmentByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemCreateForm.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemCreateForm.razor new file mode 100644 index 0000000..2548d80 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using MudBlazor +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Quantity Minus + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string NegativeAdjustmentId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateNegativeAdjustmentItemRequest _model = new() { Movement = 1 }; + private NegativeAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await NegativeAdjustmentService.GetNegativeAdjustmentLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.NegativeAdjustmentId = NegativeAdjustmentId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await NegativeAdjustmentService.CreateNegativeAdjustmentItemAsync(_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/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemDataTable.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemDataTable.razor new file mode 100644 index 0000000..9a8cc2d --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.NegativeAdjustment +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Adjustment Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + Warehouse + Quantity Minus + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string NegativeAdjustmentId { 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 { ["NegativeAdjustmentId"] = NegativeAdjustmentId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_NegativeAdjustmentItemCreateForm>("Add Adjustment Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(NegativeAdjustmentItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_NegativeAdjustmentItemUpdateForm>("Edit Adjustment Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(NegativeAdjustmentItemResponse 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 NegativeAdjustmentService.DeleteNegativeAdjustmentItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemUpdateForm.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemUpdateForm.razor new file mode 100644 index 0000000..81d2a5d --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using MudBlazor +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Quantity Minus + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public NegativeAdjustmentItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateNegativeAdjustmentItemRequest _model = new(); + private NegativeAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await NegativeAdjustmentService.GetNegativeAdjustmentLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await NegativeAdjustmentService.UpdateNegativeAdjustmentItemAsync(_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/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentUpdateForm.razor b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentUpdateForm.razor new file mode 100644 index 0000000..4030b71 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Components/_NegativeAdjustmentUpdateForm.razor @@ -0,0 +1,245 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs +@using Indotalent.Features.Inventory.NegativeAdjustment.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject NegativeAdjustmentService NegativeAdjustmentService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Adjustment + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Adjustment Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_NegativeAdjustmentItemDataTable Items="_items" NegativeAdjustmentId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Line Items + @_items.Count +
+
+ Total Qty Minus + @_items.Sum(x => x.Movement) +
+
+
+ + + 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 UpdateNegativeAdjustmentRequest 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 UpdateNegativeAdjustmentValidator _validator = new(); + private UpdateNegativeAdjustmentRequest _model = new(); + private List _items = new(); + private NegativeAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await NegativeAdjustmentService.GetNegativeAdjustmentLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await NegativeAdjustmentService.GetNegativeAdjustmentByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 NegativeAdjustmentService.GetNegativeAdjustmentByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = NegativeAdjustmentPdfGenerator.Generate(response.Value); + var fileName = $"NegativeAdjustment_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await NegativeAdjustmentService.UpdateNegativeAdjustmentAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentHandler.cs new file mode 100644 index 0000000..2b7f901 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class CreateNegativeAdjustmentRequest +{ + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { get; set; } + public string? Description { get; set; } +} + +public class CreateNegativeAdjustmentResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateNegativeAdjustmentCommand(CreateNegativeAdjustmentRequest Data) : IRequest; + +public class CreateNegativeAdjustmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateNegativeAdjustmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateNegativeAdjustmentCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.NegativeAdjustment); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.NegativeAdjustment + { + AutoNumber = autoNo, + AdjustmentDate = request.Data.AdjustmentDate, + Status = request.Data.Status, + Description = request.Data.Description + }; + + _context.NegativeAdjustment.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateNegativeAdjustmentResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentItemHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentItemHandler.cs new file mode 100644 index 0000000..b5a00c4 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class CreateNegativeAdjustmentItemRequest +{ + public string? NegativeAdjustmentId { get; set; } + public string? ProductId { get; set; } + public string? WarehouseId { get; set; } + public double? Movement { get; set; } +} + +public record CreateNegativeAdjustmentItemCommand(CreateNegativeAdjustmentItemRequest Data) : IRequest; + +public class CreateNegativeAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateNegativeAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateNegativeAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.NegativeAdjustment + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.NegativeAdjustmentId, cancellationToken); + + if (master == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.NegativeAdjustment), + ModuleCode = "ADJ-", + ModuleNumber = master.AutoNumber, + MovementDate = master.AdjustmentDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = Math.Abs(request.Data.Movement ?? 0) + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentValidator.cs b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentValidator.cs new file mode 100644 index 0000000..15e4017 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/CreateNegativeAdjustmentValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class CreateNegativeAdjustmentValidator : AbstractValidator +{ + public CreateNegativeAdjustmentValidator() + { + RuleFor(x => x.AdjustmentDate).NotEmpty().WithMessage("Adjustment Date is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentByIdHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentByIdHandler.cs new file mode 100644 index 0000000..0d906ab --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public record DeleteNegativeAdjustmentByIdCommand(string Id) : IRequest; + +public class DeleteNegativeAdjustmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteNegativeAdjustmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteNegativeAdjustmentByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.NegativeAdjustment + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.NegativeAdjustment)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.NegativeAdjustment.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentItemHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentItemHandler.cs new file mode 100644 index 0000000..b377aba --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/DeleteNegativeAdjustmentItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public record DeleteNegativeAdjustmentItemCommand(string Id) : IRequest; + +public class DeleteNegativeAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteNegativeAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteNegativeAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentByIdHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentByIdHandler.cs new file mode 100644 index 0000000..df91aac --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentByIdHandler.cs @@ -0,0 +1,75 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class NegativeAdjustmentItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetNegativeAdjustmentByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { 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 List Items { get; set; } = new(); +} + +public record GetNegativeAdjustmentByIdQuery(string Id) : IRequest; + +public class GetNegativeAdjustmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetNegativeAdjustmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetNegativeAdjustmentByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.NegativeAdjustment + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.NegativeAdjustment)) + .Select(x => new NegativeAdjustmentItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }).ToListAsync(cancellationToken); + + return new GetNegativeAdjustmentByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + AdjustmentDate = master.AdjustmentDate, + Status = master.Status, + Description = master.Description, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentListHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentListHandler.cs new file mode 100644 index 0000000..e2e0ad8 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentListHandler.cs @@ -0,0 +1,37 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class GetNegativeAdjustmentListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { get; set; } + public string? Description { get; set; } +} + +public record GetNegativeAdjustmentListQuery() : IRequest>; + +public class GetNegativeAdjustmentListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetNegativeAdjustmentListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetNegativeAdjustmentListQuery request, CancellationToken cancellationToken) + { + return await _context.NegativeAdjustment + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetNegativeAdjustmentListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + AdjustmentDate = x.AdjustmentDate, + Status = x.Status, + Description = x.Description + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentLookupHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentLookupHandler.cs new file mode 100644 index 0000000..57c54bd --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/GetNegativeAdjustmentLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class NegativeAdjustmentLookupResponse +{ + public List Warehouses { 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 GetNegativeAdjustmentLookupQuery() : IRequest; + +public class GetNegativeAdjustmentLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetNegativeAdjustmentLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetNegativeAdjustmentLookupQuery request, CancellationToken cancellationToken) + { + var response = new NegativeAdjustmentLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(AdjustmentStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentHandler.cs new file mode 100644 index 0000000..9809272 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class UpdateNegativeAdjustmentRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { 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 UpdateNegativeAdjustmentCommand(UpdateNegativeAdjustmentRequest Data) : IRequest; + +public class UpdateNegativeAdjustmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateNegativeAdjustmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateNegativeAdjustmentCommand request, CancellationToken cancellationToken) + { + var entity = await _context.NegativeAdjustment + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.AdjustmentDate = request.Data.AdjustmentDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.NegativeAdjustment)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.AdjustmentDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentItemHandler.cs b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentItemHandler.cs new file mode 100644 index 0000000..b80054a --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class UpdateNegativeAdjustmentItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? WarehouseId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateNegativeAdjustmentItemCommand(UpdateNegativeAdjustmentItemRequest Data) : IRequest; + +public class UpdateNegativeAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateNegativeAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateNegativeAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.WarehouseId = request.Data.WarehouseId; + entity.Movement = Math.Abs(request.Data.Movement ?? 0); + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentValidator.cs b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentValidator.cs new file mode 100644 index 0000000..dd6c708 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/Cqrs/UpdateNegativeAdjustmentValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +public class UpdateNegativeAdjustmentValidator : AbstractValidator +{ + public UpdateNegativeAdjustmentValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.AdjustmentDate).NotEmpty().WithMessage("Adjustment Date is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/NegativeAdjustmentEndpoint.cs b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentEndpoint.cs new file mode 100644 index 0000000..1c39489 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.NegativeAdjustment; + +public static class NegativeAdjustmentEndpoint +{ + public static void MapNegativeAdjustmentEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/negative-adjustment").WithTags("NegativeAdjustments") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetNegativeAdjustmentListQuery()); + return result.ToApiResponse("Negative adjustment list retrieved successfully"); + }) + .WithName("GetNegativeAdjustmentList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetNegativeAdjustmentByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Negative adjustment detail retrieved successfully" + : $"Negative adjustment with ID {id} not found"); + }) + .WithName("GetNegativeAdjustmentById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetNegativeAdjustmentLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetNegativeAdjustmentLookup"); + + group.MapPost("/", async (CreateNegativeAdjustmentRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateNegativeAdjustmentCommand(request)); + return result.ToApiResponse("Negative adjustment has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateNegativeAdjustment"); + + group.MapPost("/update", async (UpdateNegativeAdjustmentRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateNegativeAdjustmentCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Negative adjustment not found."); + } + return result.ToApiResponse("Negative adjustment has been updated successfully"); + }) + .WithName("UpdateNegativeAdjustment"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteNegativeAdjustmentByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Negative adjustment not found."); + } + return true.ToApiResponse("Negative adjustment has been deleted successfully"); + }) + .WithName("DeleteNegativeAdjustmentById"); + + group.MapPost("/negative-adjustment-item", async (CreateNegativeAdjustmentItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateNegativeAdjustmentItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateNegativeAdjustmentItem"); + + group.MapPost("/negative-adjustment-item/update", async (UpdateNegativeAdjustmentItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateNegativeAdjustmentItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateNegativeAdjustmentItem"); + + group.MapPost("/negative-adjustment-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteNegativeAdjustmentItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteNegativeAdjustmentItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/NegativeAdjustment/NegativeAdjustmentPdfGenerator.cs b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentPdfGenerator.cs new file mode 100644 index 0000000..a861460 --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentPdfGenerator.cs @@ -0,0 +1,109 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; + +namespace Indotalent.Features.Inventory.NegativeAdjustment; + +public class NegativeAdjustmentPdfGenerator +{ + public static byte[] Generate(GetNegativeAdjustmentByIdResponse 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("NEGATIVE ADJUSTMENT") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("STOCK ADJUSTMENT (OUT)").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.AdjustmentDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Status: {data.Status.ToString().ToUpper()}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty Minus"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(30).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(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Inventory Manager )").FontSize(7); + }); + }); + }); + + 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/Inventory/NegativeAdjustment/NegativeAdjustmentService.cs b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentService.cs new file mode 100644 index 0000000..2a674cd --- /dev/null +++ b/Features/Inventory/NegativeAdjustment/NegativeAdjustmentService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.NegativeAdjustment.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.NegativeAdjustment; + +public class NegativeAdjustmentService : BaseService +{ + private readonly RestClient _client; + + public NegativeAdjustmentService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetNegativeAdjustmentListAsync() + { + var request = new RestRequest("api/negative-adjustment", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetNegativeAdjustmentByIdAsync(string id) + { + var request = new RestRequest($"api/negative-adjustment/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetNegativeAdjustmentLookupAsync() + { + var request = new RestRequest("api/negative-adjustment/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateNegativeAdjustmentAsync(CreateNegativeAdjustmentRequest data) + { + var request = new RestRequest("api/negative-adjustment", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateNegativeAdjustmentAsync(UpdateNegativeAdjustmentRequest data) + { + var request = new RestRequest("api/negative-adjustment/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteNegativeAdjustmentByIdAsync(string id) + { + var request = new RestRequest($"api/negative-adjustment/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateNegativeAdjustmentItemAsync(CreateNegativeAdjustmentItemRequest data) + { + var request = new RestRequest("api/negative-adjustment/negative-adjustment-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateNegativeAdjustmentItemAsync(UpdateNegativeAdjustmentItemRequest data) + { + var request = new RestRequest("api/negative-adjustment/negative-adjustment-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteNegativeAdjustmentItemAsync(string id) + { + var request = new RestRequest($"api/negative-adjustment/negative-adjustment-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/Inventory/PositiveAdjustment/Components/PositiveAdjustmentPage.razor b/Features/Inventory/PositiveAdjustment/Components/PositiveAdjustmentPage.razor new file mode 100644 index 0000000..b8a1c37 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/PositiveAdjustmentPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/positive-adjustment" +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using Indotalent.Features.Inventory.PositiveAdjustment.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PositiveAdjustmentCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PositiveAdjustmentUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PositiveAdjustmentDataTable 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 UpdatePositiveAdjustmentRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePositiveAdjustmentRequest 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/PositiveAdjustment/Components/_PositiveAdjustmentCreateForm.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentCreateForm.razor new file mode 100644 index 0000000..e033e5d --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentCreateForm.razor @@ -0,0 +1,116 @@ +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject ISnackbar Snackbar + + +
+ +
+ Add Positive Adjustment + Record stock additions to specific warehouses. +
+
+
+ + + + + Basic Information + + + Adjustment Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Adjustment + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePositiveAdjustmentValidator _validator = new(); + private CreatePositiveAdjustmentRequest _model = new() { AdjustmentDate = DateTime.Today }; + private PositiveAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PositiveAdjustmentService.GetPositiveAdjustmentLookupAsync(); + 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 PositiveAdjustmentService.CreatePositiveAdjustmentAsync(_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/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentDataTable.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentDataTable.razor new file mode 100644 index 0000000..b3e79a1 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentDataTable.razor @@ -0,0 +1,377 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.PositiveAdjustment +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Positive Adjustment + Record stock additions and inventory corrections. +
+
+ + / + Inventory + / + Positive Adjustment +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Positive Adjustment + + } +
+
+ + + + + + Number + + + Adjustment Date + + + Description + + + Status + + + + + + + @context.AutoNumber + @context.AdjustmentDate?.ToString("yyyy-MM-dd") + @context.Description + + @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 GetPositiveAdjustmentListResponse? _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 PositiveAdjustmentService.GetPositiveAdjustmentListAsync(); + 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.Description?.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("PositiveAdjustments"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Adjustment Date"; + worksheet.Cell(currentRow, 3).Value = "Description"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.AdjustmentDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.Description; + worksheet.Cell(currentRow, 4).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", "PositiveAdjustment_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 PositiveAdjustmentService.GetPositiveAdjustmentByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePositiveAdjustmentRequest + { + Id = detail.Id, + AdjustmentDate = detail.AdjustmentDate, + Status = detail.Status, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 PositiveAdjustmentService.DeletePositiveAdjustmentByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemCreateForm.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemCreateForm.razor new file mode 100644 index 0000000..be98015 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using MudBlazor +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Quantity Plus + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string PositiveAdjustmentId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreatePositiveAdjustmentItemRequest _model = new() { Movement = 1 }; + private PositiveAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PositiveAdjustmentService.GetPositiveAdjustmentLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.PositiveAdjustmentId = PositiveAdjustmentId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PositiveAdjustmentService.CreatePositiveAdjustmentItemAsync(_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/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemDataTable.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemDataTable.razor new file mode 100644 index 0000000..0dc1634 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.PositiveAdjustment +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Adjustment Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + Warehouse + Quantity Plus + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string PositiveAdjustmentId { 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 { ["PositiveAdjustmentId"] = PositiveAdjustmentId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PositiveAdjustmentItemCreateForm>("Add Adjustment Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(PositiveAdjustmentItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PositiveAdjustmentItemUpdateForm>("Edit Adjustment Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(PositiveAdjustmentItemResponse 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 PositiveAdjustmentService.DeletePositiveAdjustmentItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemUpdateForm.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemUpdateForm.razor new file mode 100644 index 0000000..192d6a7 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using MudBlazor +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Quantity Plus + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public PositiveAdjustmentItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdatePositiveAdjustmentItemRequest _model = new(); + private PositiveAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PositiveAdjustmentService.GetPositiveAdjustmentLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PositiveAdjustmentService.UpdatePositiveAdjustmentItemAsync(_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/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentUpdateForm.razor b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentUpdateForm.razor new file mode 100644 index 0000000..ab28bf3 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Components/_PositiveAdjustmentUpdateForm.razor @@ -0,0 +1,245 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs +@using Indotalent.Features.Inventory.PositiveAdjustment.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PositiveAdjustmentService PositiveAdjustmentService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Adjustment + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Adjustment Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_PositiveAdjustmentItemDataTable Items="_items" PositiveAdjustmentId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Line Items + @_items.Count +
+
+ Total Qty Plus + @_items.Sum(x => x.Movement) +
+
+
+ + + 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 UpdatePositiveAdjustmentRequest 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 UpdatePositiveAdjustmentValidator _validator = new(); + private UpdatePositiveAdjustmentRequest _model = new(); + private List _items = new(); + private PositiveAdjustmentLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PositiveAdjustmentService.GetPositiveAdjustmentLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await PositiveAdjustmentService.GetPositiveAdjustmentByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 PositiveAdjustmentService.GetPositiveAdjustmentByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PositiveAdjustmentPdfGenerator.Generate(response.Value); + var fileName = $"PositiveAdjustment_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await PositiveAdjustmentService.UpdatePositiveAdjustmentAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentHandler.cs new file mode 100644 index 0000000..7dee3ef --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class CreatePositiveAdjustmentRequest +{ + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { get; set; } + public string? Description { get; set; } +} + +public class CreatePositiveAdjustmentResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePositiveAdjustmentCommand(CreatePositiveAdjustmentRequest Data) : IRequest; + +public class CreatePositiveAdjustmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePositiveAdjustmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePositiveAdjustmentCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PositiveAdjustment); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PositiveAdjustment + { + AutoNumber = autoNo, + AdjustmentDate = request.Data.AdjustmentDate, + Status = request.Data.Status, + Description = request.Data.Description + }; + + _context.PositiveAdjustment.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePositiveAdjustmentResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentItemHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentItemHandler.cs new file mode 100644 index 0000000..f894af7 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class CreatePositiveAdjustmentItemRequest +{ + public string? PositiveAdjustmentId { get; set; } + public string? ProductId { get; set; } + public string? WarehouseId { get; set; } + public double? Movement { get; set; } +} + +public record CreatePositiveAdjustmentItemCommand(CreatePositiveAdjustmentItemRequest Data) : IRequest; + +public class CreatePositiveAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePositiveAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePositiveAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.PositiveAdjustment + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.PositiveAdjustmentId, cancellationToken); + + if (master == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.PositiveAdjustment), + ModuleCode = "ADJ+", + ModuleNumber = master.AutoNumber, + MovementDate = master.AdjustmentDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentValidator.cs b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentValidator.cs new file mode 100644 index 0000000..4d29ba9 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/CreatePositiveAdjustmentValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class CreatePositiveAdjustmentValidator : AbstractValidator +{ + public CreatePositiveAdjustmentValidator() + { + RuleFor(x => x.AdjustmentDate).NotEmpty().WithMessage("Adjustment Date is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentByIdHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentByIdHandler.cs new file mode 100644 index 0000000..d8b46e4 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public record DeletePositiveAdjustmentByIdCommand(string Id) : IRequest; + +public class DeletePositiveAdjustmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePositiveAdjustmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePositiveAdjustmentByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PositiveAdjustment + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.PositiveAdjustment)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.PositiveAdjustment.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentItemHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentItemHandler.cs new file mode 100644 index 0000000..df4c5ef --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/DeletePositiveAdjustmentItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public record DeletePositiveAdjustmentItemCommand(string Id) : IRequest; + +public class DeletePositiveAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePositiveAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePositiveAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentByIdHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentByIdHandler.cs new file mode 100644 index 0000000..09e0701 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentByIdHandler.cs @@ -0,0 +1,75 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class PositiveAdjustmentItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetPositiveAdjustmentByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { 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 List Items { get; set; } = new(); +} + +public record GetPositiveAdjustmentByIdQuery(string Id) : IRequest; + +public class GetPositiveAdjustmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPositiveAdjustmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPositiveAdjustmentByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.PositiveAdjustment + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.PositiveAdjustment)) + .Select(x => new PositiveAdjustmentItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }).ToListAsync(cancellationToken); + + return new GetPositiveAdjustmentByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + AdjustmentDate = master.AdjustmentDate, + Status = master.Status, + Description = master.Description, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentListHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentListHandler.cs new file mode 100644 index 0000000..4ab431e --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentListHandler.cs @@ -0,0 +1,37 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class GetPositiveAdjustmentListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { get; set; } + public string? Description { get; set; } +} + +public record GetPositiveAdjustmentListQuery() : IRequest>; + +public class GetPositiveAdjustmentListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPositiveAdjustmentListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPositiveAdjustmentListQuery request, CancellationToken cancellationToken) + { + return await _context.PositiveAdjustment + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPositiveAdjustmentListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + AdjustmentDate = x.AdjustmentDate, + Status = x.Status, + Description = x.Description + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentLookupHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentLookupHandler.cs new file mode 100644 index 0000000..d4b78a5 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/GetPositiveAdjustmentLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class PositiveAdjustmentLookupResponse +{ + public List Warehouses { 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 GetPositiveAdjustmentLookupQuery() : IRequest; + +public class GetPositiveAdjustmentLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPositiveAdjustmentLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPositiveAdjustmentLookupQuery request, CancellationToken cancellationToken) + { + var response = new PositiveAdjustmentLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(AdjustmentStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentHandler.cs new file mode 100644 index 0000000..c851455 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class UpdatePositiveAdjustmentRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? AdjustmentDate { get; set; } + public Data.Enums.AdjustmentStatus Status { 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 UpdatePositiveAdjustmentCommand(UpdatePositiveAdjustmentRequest Data) : IRequest; + +public class UpdatePositiveAdjustmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePositiveAdjustmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePositiveAdjustmentCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PositiveAdjustment + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.AdjustmentDate = request.Data.AdjustmentDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.PositiveAdjustment)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.AdjustmentDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentItemHandler.cs b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentItemHandler.cs new file mode 100644 index 0000000..c925821 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class UpdatePositiveAdjustmentItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? WarehouseId { get; set; } + public double? Movement { get; set; } +} + +public record UpdatePositiveAdjustmentItemCommand(UpdatePositiveAdjustmentItemRequest Data) : IRequest; + +public class UpdatePositiveAdjustmentItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePositiveAdjustmentItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePositiveAdjustmentItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.WarehouseId = request.Data.WarehouseId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentValidator.cs b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentValidator.cs new file mode 100644 index 0000000..a8be7f2 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/Cqrs/UpdatePositiveAdjustmentValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +public class UpdatePositiveAdjustmentValidator : AbstractValidator +{ + public UpdatePositiveAdjustmentValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.AdjustmentDate).NotEmpty().WithMessage("Adjustment Date is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/PositiveAdjustmentEndpoint.cs b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentEndpoint.cs new file mode 100644 index 0000000..edb360f --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.PositiveAdjustment; + +public static class PositiveAdjustmentEndpoint +{ + public static void MapPositiveAdjustmentEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/positive-adjustment").WithTags("PositiveAdjustments") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPositiveAdjustmentListQuery()); + return result.ToApiResponse("Positive adjustment list retrieved successfully"); + }) + .WithName("GetPositiveAdjustmentList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPositiveAdjustmentByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Positive adjustment detail retrieved successfully" + : $"Positive adjustment with ID {id} not found"); + }) + .WithName("GetPositiveAdjustmentById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPositiveAdjustmentLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPositiveAdjustmentLookup"); + + group.MapPost("/", async (CreatePositiveAdjustmentRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePositiveAdjustmentCommand(request)); + return result.ToApiResponse("Positive adjustment has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePositiveAdjustment"); + + group.MapPost("/update", async (UpdatePositiveAdjustmentRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePositiveAdjustmentCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Positive adjustment not found."); + } + return result.ToApiResponse("Positive adjustment has been updated successfully"); + }) + .WithName("UpdatePositiveAdjustment"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePositiveAdjustmentByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Positive adjustment not found."); + } + return true.ToApiResponse("Positive adjustment has been deleted successfully"); + }) + .WithName("DeletePositiveAdjustmentById"); + + group.MapPost("/positive-adjustment-item", async (CreatePositiveAdjustmentItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePositiveAdjustmentItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreatePositiveAdjustmentItem"); + + group.MapPost("/positive-adjustment-item/update", async (UpdatePositiveAdjustmentItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePositiveAdjustmentItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdatePositiveAdjustmentItem"); + + group.MapPost("/positive-adjustment-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePositiveAdjustmentItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeletePositiveAdjustmentItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/PositiveAdjustment/PositiveAdjustmentPdfGenerator.cs b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentPdfGenerator.cs new file mode 100644 index 0000000..b2281eb --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentPdfGenerator.cs @@ -0,0 +1,109 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; + +namespace Indotalent.Features.Inventory.PositiveAdjustment; + +public class PositiveAdjustmentPdfGenerator +{ + public static byte[] Generate(GetPositiveAdjustmentByIdResponse 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("POSITIVE ADJUSTMENT") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("STOCK ADJUSTMENT (IN)").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.AdjustmentDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Status: {data.Status.ToString().ToUpper()}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty Plus"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(30).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(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Inventory Manager )").FontSize(7); + }); + }); + }); + + 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/Inventory/PositiveAdjustment/PositiveAdjustmentService.cs b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentService.cs new file mode 100644 index 0000000..78407e0 --- /dev/null +++ b/Features/Inventory/PositiveAdjustment/PositiveAdjustmentService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.PositiveAdjustment.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.PositiveAdjustment; + +public class PositiveAdjustmentService : BaseService +{ + private readonly RestClient _client; + + public PositiveAdjustmentService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPositiveAdjustmentListAsync() + { + var request = new RestRequest("api/positive-adjustment", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPositiveAdjustmentByIdAsync(string id) + { + var request = new RestRequest($"api/positive-adjustment/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPositiveAdjustmentLookupAsync() + { + var request = new RestRequest("api/positive-adjustment/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePositiveAdjustmentAsync(CreatePositiveAdjustmentRequest data) + { + var request = new RestRequest("api/positive-adjustment", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePositiveAdjustmentAsync(UpdatePositiveAdjustmentRequest data) + { + var request = new RestRequest("api/positive-adjustment/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePositiveAdjustmentByIdAsync(string id) + { + var request = new RestRequest($"api/positive-adjustment/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreatePositiveAdjustmentItemAsync(CreatePositiveAdjustmentItemRequest data) + { + var request = new RestRequest("api/positive-adjustment/positive-adjustment-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePositiveAdjustmentItemAsync(UpdatePositiveAdjustmentItemRequest data) + { + var request = new RestRequest("api/positive-adjustment/positive-adjustment-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePositiveAdjustmentItemAsync(string id) + { + var request = new RestRequest($"api/positive-adjustment/positive-adjustment-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/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..61aa726 --- /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..11096e8 --- /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..db6f9b4 --- /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..6fadfcf --- /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..ca7f29e --- /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..19e3b00 --- /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/Scrapping/Components/ScrappingPage.razor b/Features/Inventory/Scrapping/Components/ScrappingPage.razor new file mode 100644 index 0000000..978c95f --- /dev/null +++ b/Features/Inventory/Scrapping/Components/ScrappingPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/scrapping" +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using Indotalent.Features.Inventory.Scrapping.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ScrappingCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ScrappingUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_ScrappingDataTable 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 UpdateScrappingRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateScrappingRequest 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/Scrapping/Components/_ScrappingCreateForm.razor b/Features/Inventory/Scrapping/Components/_ScrappingCreateForm.razor new file mode 100644 index 0000000..42dad9b --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingCreateForm.razor @@ -0,0 +1,127 @@ +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ScrappingService ScrappingService +@inject ISnackbar Snackbar + + +
+ +
+ Add Scrapping + Create a new inventory scrapping session. +
+
+
+ + + + + Scrapping Header + + + Scrapping Date + + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Scrapping + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateScrappingValidator _validator = new(); + private CreateScrappingRequest _model = new() { ScrappingDate = DateTime.Today }; + private ScrappingLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ScrappingService.GetScrappingLookupAsync(); + 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 ScrappingService.CreateScrappingAsync(_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/Inventory/Scrapping/Components/_ScrappingDataTable.razor b/Features/Inventory/Scrapping/Components/_ScrappingDataTable.razor new file mode 100644 index 0000000..e2e0ef9 --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingDataTable.razor @@ -0,0 +1,378 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Scrapping +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ScrappingService ScrappingService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Scrapping + Manage damaged or expired inventory disposal. +
+
+ + / + Inventory + / + Scrapping +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Scrapping + + } +
+
+ + + + + + Number + + + Date + + + Warehouse + + + Status + + + + + + + @context.AutoNumber + @context.ScrappingDate?.ToString("yyyy-MM-dd") + @context.WarehouseName + + @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 GetScrappingListResponse? _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 ScrappingService.GetScrappingListAsync(); + 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.WarehouseName?.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("Scrappings"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Scrapping Date"; + worksheet.Cell(currentRow, 3).Value = "Warehouse"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.ScrappingDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.WarehouseName; + worksheet.Cell(currentRow, 4).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", "Scrapping_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 ScrappingService.GetScrappingByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateScrappingRequest + { + Id = detail.Id, + AutoNumber = detail.AutoNumber, + ScrappingDate = detail.ScrappingDate, + Status = detail.Status, + Description = detail.Description, + WarehouseId = detail.WarehouseId, + 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 ScrappingService.DeleteScrappingByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Components/_ScrappingItemCreateForm.razor b/Features/Inventory/Scrapping/Components/_ScrappingItemCreateForm.razor new file mode 100644 index 0000000..b093ee8 --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingItemCreateForm.razor @@ -0,0 +1,83 @@ +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using MudBlazor +@inject ScrappingService ScrappingService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string ScrappingId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateScrappingItemRequest _model = new() { Movement = 1 }; + private ScrappingLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ScrappingService.GetScrappingLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.ScrappingId = ScrappingId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await ScrappingService.CreateScrappingItemAsync(_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/Inventory/Scrapping/Components/_ScrappingItemDataTable.razor b/Features/Inventory/Scrapping/Components/_ScrappingItemDataTable.razor new file mode 100644 index 0000000..ad30bfc --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingItemDataTable.razor @@ -0,0 +1,88 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Scrapping +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject ScrappingService ScrappingService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Scrapping Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + Quantity + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.Movement?.ToString("N2") + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string ScrappingId { 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 { ["ScrappingId"] = ScrappingId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_ScrappingItemCreateForm>("Add Scrapping Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(ScrappingItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_ScrappingItemUpdateForm>("Edit Scrapping Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(ScrappingItemResponse 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 ScrappingService.DeleteScrappingItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Components/_ScrappingItemUpdateForm.razor b/Features/Inventory/Scrapping/Components/_ScrappingItemUpdateForm.razor new file mode 100644 index 0000000..e167ef4 --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingItemUpdateForm.razor @@ -0,0 +1,84 @@ +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using MudBlazor +@inject ScrappingService ScrappingService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public ScrappingItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateScrappingItemRequest _model = new(); + private ScrappingLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ScrappingService.GetScrappingLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await ScrappingService.UpdateScrappingItemAsync(_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/Inventory/Scrapping/Components/_ScrappingUpdateForm.razor b/Features/Inventory/Scrapping/Components/_ScrappingUpdateForm.razor new file mode 100644 index 0000000..af02794 --- /dev/null +++ b/Features/Inventory/Scrapping/Components/_ScrappingUpdateForm.razor @@ -0,0 +1,259 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.Scrapping.Cqrs +@using Indotalent.Features.Inventory.Scrapping.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject ScrappingService ScrappingService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Scrapping + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Scrapping Header + + + Scrapping Date + + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_ScrappingItemDataTable Items="_items" ScrappingId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Disposal Items + @_items.Count +
+ +
+ Total Quantity + @_items.Sum(x => x.Movement)?.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 UpdateScrappingRequest 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 UpdateScrappingValidator _validator = new(); + private UpdateScrappingRequest _model = new(); + private List _items = new(); + private ScrappingLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await ScrappingService.GetScrappingLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await ScrappingService.GetScrappingByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 ScrappingService.GetScrappingByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = ScrappingPdfGenerator.Generate(response.Value); + var fileName = $"Scrapping_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await ScrappingService.UpdateScrappingAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/Scrapping/Cqrs/CreateScrappingHandler.cs b/Features/Inventory/Scrapping/Cqrs/CreateScrappingHandler.cs new file mode 100644 index 0000000..77e5cc7 --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/CreateScrappingHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class CreateScrappingRequest +{ + public DateTime? ScrappingDate { get; set; } + public Data.Enums.ScrappingStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } +} + +public class CreateScrappingResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateScrappingCommand(CreateScrappingRequest Data) : IRequest; + +public class CreateScrappingHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateScrappingHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateScrappingCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Scrapping); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Scrapping + { + AutoNumber = autoNo, + ScrappingDate = request.Data.ScrappingDate, + Status = request.Data.Status, + Description = request.Data.Description, + WarehouseId = request.Data.WarehouseId + }; + + _context.Scrapping.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateScrappingResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/CreateScrappingItemHandler.cs b/Features/Inventory/Scrapping/Cqrs/CreateScrappingItemHandler.cs new file mode 100644 index 0000000..74f355c --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/CreateScrappingItemHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class CreateScrappingItemRequest +{ + public string? ScrappingId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateScrappingItemCommand(CreateScrappingItemRequest Data) : IRequest; + +public class CreateScrappingItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateScrappingItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateScrappingItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.Scrapping + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.ScrappingId, cancellationToken); + + if (master == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.Scrapping), + ModuleCode = "SCRP", + ModuleNumber = master.AutoNumber, + MovementDate = master.ScrappingDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = master.WarehouseId, + ProductId = request.Data.ProductId, + Movement = Math.Abs(request.Data.Movement ?? 0) + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/CreateScrappingValidator.cs b/Features/Inventory/Scrapping/Cqrs/CreateScrappingValidator.cs new file mode 100644 index 0000000..c02f910 --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/CreateScrappingValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class CreateScrappingValidator : AbstractValidator +{ + public CreateScrappingValidator() + { + RuleFor(x => x.ScrappingDate).NotEmpty().WithMessage("Scrapping Date is required"); + RuleFor(x => x.WarehouseId).NotEmpty().WithMessage("Warehouse is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/DeleteScrappingByIdHandler.cs b/Features/Inventory/Scrapping/Cqrs/DeleteScrappingByIdHandler.cs new file mode 100644 index 0000000..82683d5 --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/DeleteScrappingByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public record DeleteScrappingByIdCommand(string Id) : IRequest; + +public class DeleteScrappingByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteScrappingByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteScrappingByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Scrapping + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.Scrapping)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.Scrapping.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/DeleteScrappingItemHandler.cs b/Features/Inventory/Scrapping/Cqrs/DeleteScrappingItemHandler.cs new file mode 100644 index 0000000..3474bb0 --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/DeleteScrappingItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public record DeleteScrappingItemCommand(string Id) : IRequest; + +public class DeleteScrappingItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteScrappingItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteScrappingItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/GetScrappingByIdHandler.cs b/Features/Inventory/Scrapping/Cqrs/GetScrappingByIdHandler.cs new file mode 100644 index 0000000..10a798c --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/GetScrappingByIdHandler.cs @@ -0,0 +1,75 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class ScrappingItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public double? Movement { get; set; } +} + +public class GetScrappingByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ScrappingDate { get; set; } + public Data.Enums.ScrappingStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { 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 GetScrappingByIdQuery(string Id) : IRequest; + +public class GetScrappingByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetScrappingByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetScrappingByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.Scrapping + .AsNoTracking() + .Include(x => x.Warehouse) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.Scrapping)) + .Select(x => new ScrappingItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + Movement = x.Movement + }).ToListAsync(cancellationToken); + + return new GetScrappingByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + ScrappingDate = master.ScrappingDate, + Status = master.Status, + Description = master.Description, + WarehouseId = master.WarehouseId, + WarehouseName = master.Warehouse?.Name, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/GetScrappingListHandler.cs b/Features/Inventory/Scrapping/Cqrs/GetScrappingListHandler.cs new file mode 100644 index 0000000..ded0fbc --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/GetScrappingListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class GetScrappingListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ScrappingDate { get; set; } + public Data.Enums.ScrappingStatus Status { get; set; } + public string? WarehouseName { get; set; } + public string? Description { get; set; } +} + +public record GetScrappingListQuery() : IRequest>; + +public class GetScrappingListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetScrappingListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetScrappingListQuery request, CancellationToken cancellationToken) + { + return await _context.Scrapping + .AsNoTracking() + .Include(x => x.Warehouse) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetScrappingListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + ScrappingDate = x.ScrappingDate, + Status = x.Status, + WarehouseName = x.Warehouse!.Name, + Description = x.Description + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/GetScrappingLookupHandler.cs b/Features/Inventory/Scrapping/Cqrs/GetScrappingLookupHandler.cs new file mode 100644 index 0000000..c08030c --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/GetScrappingLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class ScrappingLookupResponse +{ + public List Warehouses { 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 GetScrappingLookupQuery() : IRequest; + +public class GetScrappingLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetScrappingLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetScrappingLookupQuery request, CancellationToken cancellationToken) + { + var response = new ScrappingLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(ScrappingStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/UpdateScrappingHandler.cs b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingHandler.cs new file mode 100644 index 0000000..34aab8c --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class UpdateScrappingRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ScrappingDate { get; set; } + public Data.Enums.ScrappingStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateScrappingCommand(UpdateScrappingRequest Data) : IRequest; + +public class UpdateScrappingHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateScrappingHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateScrappingCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Scrapping + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ScrappingDate = request.Data.ScrappingDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.WarehouseId = request.Data.WarehouseId; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.Scrapping)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.ScrappingDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.WarehouseId = entity.WarehouseId; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/UpdateScrappingItemHandler.cs b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingItemHandler.cs new file mode 100644 index 0000000..c080c4e --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingItemHandler.cs @@ -0,0 +1,36 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class UpdateScrappingItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateScrappingItemCommand(UpdateScrappingItemRequest Data) : IRequest; + +public class UpdateScrappingItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateScrappingItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateScrappingItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.Movement = Math.Abs(request.Data.Movement ?? 0); + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/Cqrs/UpdateScrappingValidator.cs b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingValidator.cs new file mode 100644 index 0000000..34a35e7 --- /dev/null +++ b/Features/Inventory/Scrapping/Cqrs/UpdateScrappingValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.Scrapping.Cqrs; + +public class UpdateScrappingValidator : AbstractValidator +{ + public UpdateScrappingValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.ScrappingDate).NotEmpty().WithMessage("Scrapping Date is required"); + RuleFor(x => x.WarehouseId).NotEmpty().WithMessage("Warehouse is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/ScrappingEndpoint.cs b/Features/Inventory/Scrapping/ScrappingEndpoint.cs new file mode 100644 index 0000000..e91f41f --- /dev/null +++ b/Features/Inventory/Scrapping/ScrappingEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.Scrapping.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.Scrapping; + +public static class ScrappingEndpoint +{ + public static void MapScrappingEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/scrapping").WithTags("Scrappings") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetScrappingListQuery()); + return result.ToApiResponse("Scrapping list retrieved successfully"); + }) + .WithName("GetScrappingList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetScrappingByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Scrapping detail retrieved successfully" + : $"Scrapping with ID {id} not found"); + }) + .WithName("GetScrappingById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetScrappingLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetScrappingLookup"); + + group.MapPost("/", async (CreateScrappingRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateScrappingCommand(request)); + return result.ToApiResponse("Scrapping has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateScrapping"); + + group.MapPost("/update", async (UpdateScrappingRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateScrappingCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Scrapping not found."); + } + return result.ToApiResponse("Scrapping has been updated successfully"); + }) + .WithName("UpdateScrapping"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteScrappingByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Scrapping not found."); + } + return true.ToApiResponse("Scrapping has been deleted successfully"); + }) + .WithName("DeleteScrappingById"); + + group.MapPost("/inventory-transaction", async (CreateScrappingItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateScrappingItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateScrappingItem"); + + group.MapPost("/inventory-transaction/update", async (UpdateScrappingItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateScrappingItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateScrappingItem"); + + group.MapPost("/inventory-transaction/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteScrappingItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteScrappingItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/Scrapping/ScrappingPdfGenerator.cs b/Features/Inventory/Scrapping/ScrappingPdfGenerator.cs new file mode 100644 index 0000000..e4f1201 --- /dev/null +++ b/Features/Inventory/Scrapping/ScrappingPdfGenerator.cs @@ -0,0 +1,107 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.Scrapping.Cqrs; + +namespace Indotalent.Features.Inventory.Scrapping; + +public class ScrappingPdfGenerator +{ + public static byte[] Generate(GetScrappingByIdResponse 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("STOCK SCRAPPING") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("WASTE MANAGEMENT").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.ScrappingDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Warehouse: {data.WarehouseName}").FontColor(textSlate); + col.Item().Text($"Status: {data.Status.ToString().ToUpper()}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(7); + columns.RelativeColumn(3); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Description"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Scrapped Qty"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).AlignRight().Text(item.Movement?.ToString("N2")); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(30).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Reason / Description:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + }); + + row.ConstantItem(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse Manager )").FontSize(7); + }); + }); + }); + + 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/Inventory/Scrapping/ScrappingService.cs b/Features/Inventory/Scrapping/ScrappingService.cs new file mode 100644 index 0000000..1dc4117 --- /dev/null +++ b/Features/Inventory/Scrapping/ScrappingService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.Scrapping.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.Scrapping; + +public class ScrappingService : BaseService +{ + private readonly RestClient _client; + + public ScrappingService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetScrappingListAsync() + { + var request = new RestRequest("api/scrapping", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetScrappingByIdAsync(string id) + { + var request = new RestRequest($"api/scrapping/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetScrappingLookupAsync() + { + var request = new RestRequest("api/scrapping/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateScrappingAsync(CreateScrappingRequest data) + { + var request = new RestRequest("api/scrapping", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateScrappingAsync(UpdateScrappingRequest data) + { + var request = new RestRequest("api/scrapping/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteScrappingByIdAsync(string id) + { + var request = new RestRequest($"api/scrapping/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateScrappingItemAsync(CreateScrappingItemRequest data) + { + var request = new RestRequest("api/scrapping/inventory-transaction", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateScrappingItemAsync(UpdateScrappingItemRequest data) + { + var request = new RestRequest("api/scrapping/inventory-transaction/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteScrappingItemAsync(string id) + { + var request = new RestRequest($"api/scrapping/inventory-transaction/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Components/StockCountPage.razor b/Features/Inventory/StockCount/Components/StockCountPage.razor new file mode 100644 index 0000000..2ce353e --- /dev/null +++ b/Features/Inventory/StockCount/Components/StockCountPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/stock-count" +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using Indotalent.Features.Inventory.StockCount.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_StockCountCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_StockCountUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_StockCountDataTable 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 UpdateStockCountRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateStockCountRequest 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/StockCount/Components/_StockCountCreateForm.razor b/Features/Inventory/StockCount/Components/_StockCountCreateForm.razor new file mode 100644 index 0000000..cc4c7c5 --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountCreateForm.razor @@ -0,0 +1,127 @@ +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject StockCountService StockCountService +@inject ISnackbar Snackbar + + +
+ +
+ Add Stock Count + Create a new inventory audit session. +
+
+
+ + + + + Audit Header + + + Count Date + + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Audit + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateStockCountValidator _validator = new(); + private CreateStockCountRequest _model = new() { CountDate = DateTime.Today }; + private StockCountLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await StockCountService.GetStockCountLookupAsync(); + 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 StockCountService.CreateStockCountAsync(_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/Inventory/StockCount/Components/_StockCountDataTable.razor b/Features/Inventory/StockCount/Components/_StockCountDataTable.razor new file mode 100644 index 0000000..0483152 --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountDataTable.razor @@ -0,0 +1,379 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.StockCount +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject StockCountService StockCountService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Stock Count + Inventory audit and stock level verification. +
+
+ + / + Inventory + / + Stock Count +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Stock Count + + } +
+
+ + + + + + Number + + + Count Date + + + Warehouse + + + Status + + + + + + + @context.AutoNumber + @context.CountDate?.ToString("yyyy-MM-dd") + @context.WarehouseName + + @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 GetStockCountListResponse? _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 StockCountService.GetStockCountListAsync(); + 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.WarehouseName?.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("StockCounts"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Count Date"; + worksheet.Cell(currentRow, 3).Value = "Warehouse"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.CountDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.WarehouseName; + worksheet.Cell(currentRow, 4).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", "StockCount_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 StockCountService.GetStockCountByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateStockCountRequest + { + Id = detail.Id, + AutoNumber = detail.AutoNumber, + CountDate = detail.CountDate, + Status = detail.Status, + Description = detail.Description, + WarehouseId = detail.WarehouseId, + 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 StockCountService.DeleteStockCountByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Components/_StockCountItemCreateForm.razor b/Features/Inventory/StockCount/Components/_StockCountItemCreateForm.razor new file mode 100644 index 0000000..951958c --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountItemCreateForm.razor @@ -0,0 +1,83 @@ +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using MudBlazor +@inject StockCountService StockCountService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Actual Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string StockCountId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateStockCountItemRequest _model = new() { QtySCCount = 0 }; + private StockCountLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await StockCountService.GetStockCountLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.StockCountId = StockCountId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await StockCountService.CreateStockCountItemAsync(_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/Inventory/StockCount/Components/_StockCountItemDataTable.razor b/Features/Inventory/StockCount/Components/_StockCountItemDataTable.razor new file mode 100644 index 0000000..2159a14 --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountItemDataTable.razor @@ -0,0 +1,94 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.StockCount +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject StockCountService StockCountService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Audit Items + @if (!ReadOnly) + { + + Add Item + + } +
+ + + + Product + System Qty + Actual Qty + Delta + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.QtySCSys + @context.QtySCCount + + @context.QtySCDelta + + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string StockCountId { 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 { ["StockCountId"] = StockCountId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_StockCountItemCreateForm>("Add Audit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(StockCountItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_StockCountItemUpdateForm>("Edit Audit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(StockCountItemResponse 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 StockCountService.DeleteStockCountItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Components/_StockCountItemUpdateForm.razor b/Features/Inventory/StockCount/Components/_StockCountItemUpdateForm.razor new file mode 100644 index 0000000..fb4cba1 --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountItemUpdateForm.razor @@ -0,0 +1,84 @@ +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using MudBlazor +@inject StockCountService StockCountService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Actual Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public StockCountItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateStockCountItemRequest _model = new(); + private StockCountLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await StockCountService.GetStockCountLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.QtySCCount = Data.QtySCCount; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await StockCountService.UpdateStockCountItemAsync(_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/Inventory/StockCount/Components/_StockCountUpdateForm.razor b/Features/Inventory/StockCount/Components/_StockCountUpdateForm.razor new file mode 100644 index 0000000..53b880c --- /dev/null +++ b/Features/Inventory/StockCount/Components/_StockCountUpdateForm.razor @@ -0,0 +1,267 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.StockCount.Cqrs +@using Indotalent.Features.Inventory.StockCount.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject StockCountService StockCountService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Stock Count + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Audit Header + + + Count Date + + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_StockCountItemDataTable Items="_items" StockCountId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Lines Verified + @_items.Count +
+
+ Total System Qty + @_items.Sum(x => x.QtySCSys) +
+
+ Total Actual Qty + @_items.Sum(x => x.QtySCCount) +
+ +
+ Total Delta + @_items.Sum(x => x.QtySCDelta) +
+
+
+ + + 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 UpdateStockCountRequest 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 UpdateStockCountValidator _validator = new(); + private UpdateStockCountRequest _model = new(); + private List _items = new(); + private StockCountLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await StockCountService.GetStockCountLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await StockCountService.GetStockCountByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 StockCountService.GetStockCountByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = StockCountPdfGenerator.Generate(response.Value); + var fileName = $"StockCount_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await StockCountService.UpdateStockCountAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/StockCount/Cqrs/CreateStockCountHandler.cs b/Features/Inventory/StockCount/Cqrs/CreateStockCountHandler.cs new file mode 100644 index 0000000..4bcbbc1 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/CreateStockCountHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class CreateStockCountRequest +{ + public DateTime? CountDate { get; set; } + public Data.Enums.StockCountStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } +} + +public class CreateStockCountResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateStockCountCommand(CreateStockCountRequest Data) : IRequest; + +public class CreateStockCountHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateStockCountHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateStockCountCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.StockCount); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.StockCount + { + AutoNumber = autoNo, + CountDate = request.Data.CountDate, + Status = request.Data.Status, + Description = request.Data.Description, + WarehouseId = request.Data.WarehouseId + }; + + _context.StockCount.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateStockCountResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/CreateStockCountItemHandler.cs b/Features/Inventory/StockCount/Cqrs/CreateStockCountItemHandler.cs new file mode 100644 index 0000000..a9ed636 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/CreateStockCountItemHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class CreateStockCountItemRequest +{ + public string? StockCountId { get; set; } + public string? ProductId { get; set; } + public double? QtySCCount { get; set; } +} + +public record CreateStockCountItemCommand(CreateStockCountItemRequest Data) : IRequest; + +public class CreateStockCountItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateStockCountItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateStockCountItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.StockCount + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.StockCountId, cancellationToken); + + if (master == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.StockCount), + ModuleCode = "COUNT", + ModuleNumber = master.AutoNumber, + MovementDate = master.CountDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = master.WarehouseId, + ProductId = request.Data.ProductId, + QtySCCount = Math.Abs(request.Data.QtySCCount ?? 0) + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/CreateStockCountValidator.cs b/Features/Inventory/StockCount/Cqrs/CreateStockCountValidator.cs new file mode 100644 index 0000000..5bf7e7e --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/CreateStockCountValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class CreateStockCountValidator : AbstractValidator +{ + public CreateStockCountValidator() + { + RuleFor(x => x.CountDate).NotEmpty().WithMessage("Count Date is required"); + RuleFor(x => x.WarehouseId).NotEmpty().WithMessage("Warehouse is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/DeleteStockCountByIdHandler.cs b/Features/Inventory/StockCount/Cqrs/DeleteStockCountByIdHandler.cs new file mode 100644 index 0000000..9759c9c --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/DeleteStockCountByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public record DeleteStockCountByIdCommand(string Id) : IRequest; + +public class DeleteStockCountByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteStockCountByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteStockCountByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.StockCount + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.StockCount)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.StockCount.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/DeleteStockCountItemHandler.cs b/Features/Inventory/StockCount/Cqrs/DeleteStockCountItemHandler.cs new file mode 100644 index 0000000..5a5768f --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/DeleteStockCountItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public record DeleteStockCountItemCommand(string Id) : IRequest; + +public class DeleteStockCountItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteStockCountItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteStockCountItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/GetStockCountByIdHandler.cs b/Features/Inventory/StockCount/Cqrs/GetStockCountByIdHandler.cs new file mode 100644 index 0000000..22c3e8c --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/GetStockCountByIdHandler.cs @@ -0,0 +1,79 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class StockCountItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public double? QtySCSys { get; set; } + public double? QtySCCount { get; set; } + public double? QtySCDelta { get; set; } +} + +public class GetStockCountByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? CountDate { get; set; } + public Data.Enums.StockCountStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { 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 GetStockCountByIdQuery(string Id) : IRequest; + +public class GetStockCountByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetStockCountByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetStockCountByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.StockCount + .AsNoTracking() + .Include(x => x.Warehouse) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.StockCount)) + .Select(x => new StockCountItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + QtySCSys = x.QtySCSys, + QtySCCount = x.QtySCCount, + QtySCDelta = x.QtySCDelta + }).ToListAsync(cancellationToken); + + return new GetStockCountByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + CountDate = master.CountDate, + Status = master.Status, + Description = master.Description, + WarehouseId = master.WarehouseId, + WarehouseName = master.Warehouse?.Name, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/GetStockCountListHandler.cs b/Features/Inventory/StockCount/Cqrs/GetStockCountListHandler.cs new file mode 100644 index 0000000..0772ce4 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/GetStockCountListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class GetStockCountListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? CountDate { get; set; } + public Data.Enums.StockCountStatus Status { get; set; } + public string? WarehouseName { get; set; } + public string? Description { get; set; } +} + +public record GetStockCountListQuery() : IRequest>; + +public class GetStockCountListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetStockCountListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetStockCountListQuery request, CancellationToken cancellationToken) + { + return await _context.StockCount + .AsNoTracking() + .Include(x => x.Warehouse) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetStockCountListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + CountDate = x.CountDate, + Status = x.Status, + WarehouseName = x.Warehouse!.Name, + Description = x.Description + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/GetStockCountLookupHandler.cs b/Features/Inventory/StockCount/Cqrs/GetStockCountLookupHandler.cs new file mode 100644 index 0000000..6e81158 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/GetStockCountLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class StockCountLookupResponse +{ + public List Warehouses { 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 GetStockCountLookupQuery() : IRequest; + +public class GetStockCountLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetStockCountLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetStockCountLookupQuery request, CancellationToken cancellationToken) + { + var response = new StockCountLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(StockCountStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/UpdateStockCountHandler.cs b/Features/Inventory/StockCount/Cqrs/UpdateStockCountHandler.cs new file mode 100644 index 0000000..488a3bd --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/UpdateStockCountHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class UpdateStockCountRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? CountDate { get; set; } + public Data.Enums.StockCountStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateStockCountCommand(UpdateStockCountRequest Data) : IRequest; + +public class UpdateStockCountHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateStockCountHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateStockCountCommand request, CancellationToken cancellationToken) + { + var entity = await _context.StockCount + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.CountDate = request.Data.CountDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.WarehouseId = request.Data.WarehouseId; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.StockCount)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.CountDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.WarehouseId = entity.WarehouseId; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/UpdateStockCountItemHandler.cs b/Features/Inventory/StockCount/Cqrs/UpdateStockCountItemHandler.cs new file mode 100644 index 0000000..988cc17 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/UpdateStockCountItemHandler.cs @@ -0,0 +1,36 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class UpdateStockCountItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public double? QtySCCount { get; set; } +} + +public record UpdateStockCountItemCommand(UpdateStockCountItemRequest Data) : IRequest; + +public class UpdateStockCountItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateStockCountItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateStockCountItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.QtySCCount = Math.Abs(request.Data.QtySCCount ?? 0); + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/Cqrs/UpdateStockCountValidator.cs b/Features/Inventory/StockCount/Cqrs/UpdateStockCountValidator.cs new file mode 100644 index 0000000..5b12f48 --- /dev/null +++ b/Features/Inventory/StockCount/Cqrs/UpdateStockCountValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.StockCount.Cqrs; + +public class UpdateStockCountValidator : AbstractValidator +{ + public UpdateStockCountValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.CountDate).NotEmpty().WithMessage("Count Date is required"); + RuleFor(x => x.WarehouseId).NotEmpty().WithMessage("Warehouse is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/StockCountEndpoint.cs b/Features/Inventory/StockCount/StockCountEndpoint.cs new file mode 100644 index 0000000..be7bfe2 --- /dev/null +++ b/Features/Inventory/StockCount/StockCountEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.StockCount.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.StockCount; + +public static class StockCountEndpoint +{ + public static void MapStockCountEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/stock-count").WithTags("StockCounts") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetStockCountListQuery()); + return result.ToApiResponse("Stock count list retrieved successfully"); + }) + .WithName("GetStockCountList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetStockCountByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Stock count detail retrieved successfully" + : $"Stock count with ID {id} not found"); + }) + .WithName("GetStockCountById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetStockCountLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetStockCountLookup"); + + group.MapPost("/", async (CreateStockCountRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateStockCountCommand(request)); + return result.ToApiResponse("Stock count has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateStockCount"); + + group.MapPost("/update", async (UpdateStockCountRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateStockCountCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Stock count not found."); + } + return result.ToApiResponse("Stock count has been updated successfully"); + }) + .WithName("UpdateStockCount"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteStockCountByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Stock count not found."); + } + return true.ToApiResponse("Stock count has been deleted successfully"); + }) + .WithName("DeleteStockCountById"); + + group.MapPost("/stock-count-item", async (CreateStockCountItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateStockCountItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateStockCountItem"); + + group.MapPost("/stock-count-item/update", async (UpdateStockCountItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateStockCountItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateStockCountItem"); + + group.MapPost("/stock-count-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteStockCountItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteStockCountItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/StockCount/StockCountPdfGenerator.cs b/Features/Inventory/StockCount/StockCountPdfGenerator.cs new file mode 100644 index 0000000..3e9d835 --- /dev/null +++ b/Features/Inventory/StockCount/StockCountPdfGenerator.cs @@ -0,0 +1,113 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.StockCount.Cqrs; + +namespace Indotalent.Features.Inventory.StockCount; + +public class StockCountPdfGenerator +{ + public static byte[] Generate(GetStockCountByIdResponse 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("STOCK COUNT") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("INVENTORY AUDIT").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.CountDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Warehouse: {data.WarehouseName}").FontColor(textSlate); + col.Item().Text($"Status: {data.Status.ToString().ToUpper()}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(2); + columns.RelativeColumn(2); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Sys Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Actual Qty"); + header.Cell().Element(HeaderStyle).AlignRight().Text("Delta"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).AlignRight().Text(item.QtySCSys?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.QtySCCount?.ToString()); + table.Cell().Element(CellStyle).AlignRight().Text(item.QtySCDelta?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(30).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(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Inventory Auditor )").FontSize(7); + }); + }); + }); + + 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/Inventory/StockCount/StockCountService.cs b/Features/Inventory/StockCount/StockCountService.cs new file mode 100644 index 0000000..60ba273 --- /dev/null +++ b/Features/Inventory/StockCount/StockCountService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.StockCount.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.StockCount; + +public class StockCountService : BaseService +{ + private readonly RestClient _client; + + public StockCountService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetStockCountListAsync() + { + var request = new RestRequest("api/stock-count", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetStockCountByIdAsync(string id) + { + var request = new RestRequest($"api/stock-count/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetStockCountLookupAsync() + { + var request = new RestRequest("api/stock-count/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateStockCountAsync(CreateStockCountRequest data) + { + var request = new RestRequest("api/stock-count", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateStockCountAsync(UpdateStockCountRequest data) + { + var request = new RestRequest("api/stock-count/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteStockCountByIdAsync(string id) + { + var request = new RestRequest($"api/stock-count/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateStockCountItemAsync(CreateStockCountItemRequest data) + { + var request = new RestRequest("api/stock-count/stock-count-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateStockCountItemAsync(UpdateStockCountItemRequest data) + { + var request = new RestRequest("api/stock-count/stock-count-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteStockCountItemAsync(string id) + { + var request = new RestRequest($"api/stock-count/stock-count-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/Inventory/StockReport/Components/StockReportPage.razor b/Features/Inventory/StockReport/Components/StockReportPage.razor new file mode 100644 index 0000000..a310b0b --- /dev/null +++ b/Features/Inventory/StockReport/Components/StockReportPage.razor @@ -0,0 +1,8 @@ +@page "/inventory/stock-report" +@using Indotalent.Features.Inventory.StockReport.Components +@using MudBlazor + +<_StockReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Inventory/StockReport/Components/_StockReportDataTable.razor b/Features/Inventory/StockReport/Components/_StockReportDataTable.razor new file mode 100644 index 0000000..79003ff --- /dev/null +++ b/Features/Inventory/StockReport/Components/_StockReportDataTable.razor @@ -0,0 +1,293 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.StockReport +@using Indotalent.Features.Inventory.StockReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject StockReportService StockReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Inventory Stock Report + Current stock levels aggregated by warehouse and product. +
+
+ + / + Inventory + / + Stock Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Warehouse + + + Number + + + Product Name + + + Stock + + + Status + + + Last Updated + + + + @context.WarehouseName + @context.ProductNumber + @context.ProductName + @context.Stock?.ToString("N2") + + @context.StatusName.ToUpper() + + @context.CreatedAt?.ToString("yyyy-MM-dd HH:mm") + + + +
+
+ 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 StockReportService.GetInventoryStockReportListAsync(); + 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.WarehouseName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductNumber?.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("StockReports"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Warehouse"; + worksheet.Cell(currentRow, 2).Value = "Product Number"; + worksheet.Cell(currentRow, 3).Value = "Product Name"; + worksheet.Cell(currentRow, 4).Value = "Stock"; + worksheet.Cell(currentRow, 5).Value = "Status"; + worksheet.Cell(currentRow, 6).Value = "Last Updated"; + + 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.WarehouseName; + worksheet.Cell(currentRow, 2).Value = item.ProductNumber; + worksheet.Cell(currentRow, 3).Value = item.ProductName; + worksheet.Cell(currentRow, 4).Value = item.Stock; + worksheet.Cell(currentRow, 5).Value = item.StatusName; + worksheet.Cell(currentRow, 6).Value = item.CreatedAt?.ToString("yyyy-MM-dd HH:mm"); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Stock_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/Inventory/StockReport/Cqrs/GetInventoryStockReportListHandler.cs b/Features/Inventory/StockReport/Cqrs/GetInventoryStockReportListHandler.cs new file mode 100644 index 0000000..dd00383 --- /dev/null +++ b/Features/Inventory/StockReport/Cqrs/GetInventoryStockReportListHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.StockReport.Cqrs; + +public record GetInventoryStockReportListDto +{ + public string? StatusName { get; init; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; init; } + public string? ProductId { get; set; } + public string? ProductName { get; init; } + public string? ProductNumber { get; init; } + public double? Stock { get; init; } + public DateTimeOffset? CreatedAt { get; init; } +} + +public record GetInventoryStockReportListQuery() : IRequest>; + +public class GetInventoryStockReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetInventoryStockReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetInventoryStockReportListQuery request, CancellationToken cancellationToken) + { + var results = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Warehouse) + .Include(x => x.Product) + .Where(x => + x.Product!.Physical == true && + x.Warehouse!.SystemWarehouse == false && + x.Status == Data.Enums.InventoryTransactionStatus.Confirmed + ) + .GroupBy(x => new { x.WarehouseId, x.ProductId }) + .Select(group => new GetInventoryStockReportListDto + { + WarehouseId = group.Key.WarehouseId, + ProductId = group.Key.ProductId, + WarehouseName = group.Max(x => x.Warehouse!.Name), + ProductName = group.Max(x => x.Product!.Name), + ProductNumber = group.Max(x => x.Product!.AutoNumber), + Stock = group.Sum(x => x.Stock), + StatusName = group.Max(x => x.Status.ToString()), + CreatedAt = group.Max(x => x.CreatedAt) + }) + .OrderBy(x => x.WarehouseName) + .ThenBy(x => x.ProductName) + .ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Inventory/StockReport/StockReportEndpoint.cs b/Features/Inventory/StockReport/StockReportEndpoint.cs new file mode 100644 index 0000000..f02a288 --- /dev/null +++ b/Features/Inventory/StockReport/StockReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.StockReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.StockReport; + +public static class StockReportEndpoint +{ + public static void MapStockReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/stock-report").WithTags("StockReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInventoryStockReportListQuery()); + return result.ToApiResponse("Stock report list retrieved successfully"); + }) + .WithName("GetInventoryStockReportList"); + } +} \ No newline at end of file diff --git a/Features/Inventory/StockReport/StockReportService.cs b/Features/Inventory/StockReport/StockReportService.cs new file mode 100644 index 0000000..35f24d9 --- /dev/null +++ b/Features/Inventory/StockReport/StockReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.StockReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.StockReport; + +public class StockReportService : BaseService +{ + private readonly RestClient _client; + + public StockReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetInventoryStockReportListAsync() + { + var request = new RestRequest("api/stock-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransactionReport/Components/TransactionReportPage.razor b/Features/Inventory/TransactionReport/Components/TransactionReportPage.razor new file mode 100644 index 0000000..020369d --- /dev/null +++ b/Features/Inventory/TransactionReport/Components/TransactionReportPage.razor @@ -0,0 +1,9 @@ +@page "/inventory/transaction-report" +@using Indotalent.Features.Inventory.TransactionReport.Cqrs +@using Indotalent.Features.Inventory.TransactionReport.Components +@using MudBlazor + +<_TransactionReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Inventory/TransactionReport/Components/_TransactionReportDataTable.razor b/Features/Inventory/TransactionReport/Components/_TransactionReportDataTable.razor new file mode 100644 index 0000000..5c96102 --- /dev/null +++ b/Features/Inventory/TransactionReport/Components/_TransactionReportDataTable.razor @@ -0,0 +1,314 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransactionReport +@using Indotalent.Features.Inventory.TransactionReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TransactionReportService TransactionReportService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Inventory Transaction Report + Comprehensive log of all stock movements and inventory adjustments. +
+
+ + / + Inventory + / + Transaction Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Warehouse + + + Product + + + Date + + + Module + + + Module No + + + Movement + + + Type + + + Stock + + + Status + + + + @context.WarehouseName + @context.ProductName + @context.MovementDate?.ToString("yyyy-MM-dd") + @context.ModuleName + @context.ModuleNumber + @context.Movement?.ToString("N2") + @context.TransTypeName + @context.Stock?.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 TransactionReportService.GetInventoryTransactionReportListAsync(); + 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.WarehouseName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ModuleName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ModuleNumber?.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("TransactionReports"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Warehouse"; + worksheet.Cell(currentRow, 2).Value = "Product"; + worksheet.Cell(currentRow, 3).Value = "Movement Date"; + worksheet.Cell(currentRow, 4).Value = "Module"; + worksheet.Cell(currentRow, 5).Value = "Module No"; + worksheet.Cell(currentRow, 6).Value = "Movement"; + worksheet.Cell(currentRow, 7).Value = "Type"; + worksheet.Cell(currentRow, 8).Value = "Stock"; + worksheet.Cell(currentRow, 9).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 9); + 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.WarehouseName; + worksheet.Cell(currentRow, 2).Value = item.ProductName; + worksheet.Cell(currentRow, 3).Value = item.MovementDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 4).Value = item.ModuleName; + worksheet.Cell(currentRow, 5).Value = item.ModuleNumber; + worksheet.Cell(currentRow, 6).Value = item.Movement; + worksheet.Cell(currentRow, 7).Value = item.TransTypeName; + worksheet.Cell(currentRow, 8).Value = item.Stock; + worksheet.Cell(currentRow, 9).Value = item.StatusName; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Transaction_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/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportListHandler.cs b/Features/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportListHandler.cs new file mode 100644 index 0000000..0898241 --- /dev/null +++ b/Features/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportListHandler.cs @@ -0,0 +1,76 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransactionReport.Cqrs; + +public record GetInventoryTransactionReportListDto +{ + public string? Id { get; init; } + public string? ModuleId { get; init; } + public string? ModuleName { get; init; } + public string? ModuleCode { get; init; } + public string? ModuleNumber { get; init; } + public DateTime? MovementDate { get; init; } + public string? StatusName { get; init; } + public string? AutoNumber { get; init; } + public string? WarehouseName { get; init; } + public string? ProductName { get; init; } + public double? Movement { get; init; } + public string? TransTypeName { get; init; } + public double? Stock { get; init; } + public string? WarehouseFromName { get; init; } + public string? WarehouseToName { get; init; } + public DateTimeOffset? CreatedAt { get; init; } +} + +public record GetInventoryTransactionReportListQuery() : IRequest>; + +public class GetInventoryTransactionReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetInventoryTransactionReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetInventoryTransactionReportListQuery request, CancellationToken cancellationToken) + { + var query = _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Warehouse) + .Include(x => x.Product) + .Include(x => x.WarehouseFrom) + .Include(x => x.WarehouseTo) + .Where(x => + x.Product!.Physical == true && + x.Warehouse!.SystemWarehouse == false && + x.Status == Data.Enums.InventoryTransactionStatus.Confirmed + ) + .OrderByDescending(x => x.CreatedAt); + + var results = await query.Select(x => new GetInventoryTransactionReportListDto + { + Id = x.Id, + ModuleId = x.ModuleId, + ModuleName = x.ModuleName, + ModuleCode = x.ModuleCode, + ModuleNumber = x.ModuleNumber, + MovementDate = x.MovementDate, + StatusName = x.Status.GetDescription(), + AutoNumber = x.AutoNumber, + WarehouseName = x.Warehouse != null ? x.Warehouse.Name : string.Empty, + ProductName = x.Product != null ? x.Product.AutoNumber + " " + x.Product.Name : string.Empty, + Movement = x.Movement, + TransTypeName = x.TransType.HasValue ? x.TransType.Value.GetDescription() : string.Empty, + Stock = x.Stock, + WarehouseFromName = x.WarehouseFrom != null ? x.WarehouseFrom.Name : string.Empty, + WarehouseToName = x.WarehouseTo != null ? x.WarehouseTo.Name : string.Empty, + CreatedAt = x.CreatedAt + }).ToListAsync(cancellationToken); + + return results; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportLookupHandler.cs b/Features/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportLookupHandler.cs new file mode 100644 index 0000000..a06ee0b --- /dev/null +++ b/Features/Inventory/TransactionReport/Cqrs/GetInventoryTransactionReportLookupHandler.cs @@ -0,0 +1,37 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransactionReport.Cqrs; + +public class InventoryTransactionReportLookupResponse +{ + public List Warehouses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record GetInventoryTransactionReportLookupQuery() : IRequest; + +public class GetInventoryTransactionReportLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetInventoryTransactionReportLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetInventoryTransactionReportLookupQuery request, CancellationToken cancellationToken) + { + var response = new InventoryTransactionReportLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransactionReport/TransactionReportEndpoint.cs b/Features/Inventory/TransactionReport/TransactionReportEndpoint.cs new file mode 100644 index 0000000..71e9ba7 --- /dev/null +++ b/Features/Inventory/TransactionReport/TransactionReportEndpoint.cs @@ -0,0 +1,30 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.TransactionReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.TransactionReport; + +public static class TransactionReportEndpoint +{ + public static void MapTransactionReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/transaction-report").WithTags("TransactionReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInventoryTransactionReportListQuery()); + return result.ToApiResponse("Transaction report list retrieved successfully"); + }) + .WithName("GetInventoryTransactionReportList"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetInventoryTransactionReportLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetInventoryTransactionReportLookup"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransactionReport/TransactionReportService.cs b/Features/Inventory/TransactionReport/TransactionReportService.cs new file mode 100644 index 0000000..9e1de07 --- /dev/null +++ b/Features/Inventory/TransactionReport/TransactionReportService.cs @@ -0,0 +1,38 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.TransactionReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.TransactionReport; + +public class TransactionReportService : BaseService +{ + private readonly RestClient _client; + + public TransactionReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetInventoryTransactionReportListAsync() + { + var request = new RestRequest("api/transaction-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetInventoryTransactionReportLookupAsync() + { + var request = new RestRequest("api/transaction-report/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Components/TransferInPage.razor b/Features/Inventory/TransferIn/Components/TransferInPage.razor new file mode 100644 index 0000000..77a0c35 --- /dev/null +++ b/Features/Inventory/TransferIn/Components/TransferInPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/transfer-in" +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using Indotalent.Features.Inventory.TransferIn.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TransferInCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TransferInUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_TransferInDataTable 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 UpdateTransferInRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTransferInRequest 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/TransferIn/Components/_TransferInCreateForm.razor b/Features/Inventory/TransferIn/Components/_TransferInCreateForm.razor new file mode 100644 index 0000000..18a350f --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TransferInService TransferInService +@inject ISnackbar Snackbar + + +
+ +
+ Add Transfer In + Record warehouse receipt from an existing Transfer Out. +
+
+
+ + + + + Basic Information + + + Receive Date + + + + + Ref Transfer Out + + @foreach (var item in _lookup.TransferOuts) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Receiving Notes + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Transfer In + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTransferInValidator _validator = new(); + private CreateTransferInRequest _model = new() { TransferReceiveDate = DateTime.Today }; + private TransferInLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferInService.GetTransferInLookupAsync(); + 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 TransferInService.CreateTransferInAsync(_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/Inventory/TransferIn/Components/_TransferInDataTable.razor b/Features/Inventory/TransferIn/Components/_TransferInDataTable.razor new file mode 100644 index 0000000..835da1c --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInDataTable.razor @@ -0,0 +1,385 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferIn +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TransferInService TransferInService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Transfer In + Manage stock receipts from internal transfers. +
+
+ + / + Inventory + / + Transfer In +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Transfer In + + } +
+
+ + + + + + Number + + + Receive Date + + + Ref Out + + + Destination + + + Status + + + + + + + @context.AutoNumber + @context.TransferReceiveDate?.ToString("yyyy-MM-dd") + @context.TransferOutNumber + @context.WarehouseToName + + @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 GetTransferInListResponse? _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 TransferInService.GetTransferInListAsync(); + 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.TransferOutNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.WarehouseToName?.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("TransferIns"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Receive Date"; + worksheet.Cell(currentRow, 3).Value = "Ref Out"; + worksheet.Cell(currentRow, 4).Value = "Destination"; + 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.TransferReceiveDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.TransferOutNumber; + worksheet.Cell(currentRow, 4).Value = item.WarehouseToName; + worksheet.Cell(currentRow, 5).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", "TransferIn_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 TransferInService.GetTransferInByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateTransferInRequest + { + Id = detail.Id, + TransferReceiveDate = detail.TransferReceiveDate, + Status = detail.Status, + Description = detail.Description, + TransferOutId = detail.TransferOutId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 TransferInService.DeleteTransferInByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Components/_TransferInItemCreateForm.razor b/Features/Inventory/TransferIn/Components/_TransferInItemCreateForm.razor new file mode 100644 index 0000000..c1485b2 --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInItemCreateForm.razor @@ -0,0 +1,78 @@ +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using MudBlazor +@inject TransferInService TransferInService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Quantity Received + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Item + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string TransferInId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateTransferInItemRequest _model = new() { Movement = 1 }; + private TransferInLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferInService.GetTransferInLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.TransferInId = TransferInId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TransferInService.CreateTransferInItemAsync(_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/Inventory/TransferIn/Components/_TransferInItemDataTable.razor b/Features/Inventory/TransferIn/Components/_TransferInItemDataTable.razor new file mode 100644 index 0000000..01a6acd --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInItemDataTable.razor @@ -0,0 +1,88 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferIn +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject TransferInService TransferInService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Received Items + @if (!ReadOnly) + { + + Add Line Item + + } +
+ + + + Product + Quantity Received + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string TransferInId { 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 { ["TransferInId"] = TransferInId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TransferInItemCreateForm>("Add Receipt Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(TransferInItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TransferInItemUpdateForm>("Edit Receipt Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(TransferInItemResponse 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 TransferInService.DeleteTransferInItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Components/_TransferInItemUpdateForm.razor b/Features/Inventory/TransferIn/Components/_TransferInItemUpdateForm.razor new file mode 100644 index 0000000..a5f46d6 --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInItemUpdateForm.razor @@ -0,0 +1,81 @@ +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using MudBlazor +@inject TransferInService TransferInService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Quantity Received + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public TransferInItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateTransferInItemRequest _model = new(); + private TransferInLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferInService.GetTransferInLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TransferInService.UpdateTransferInItemAsync(_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/Inventory/TransferIn/Components/_TransferInUpdateForm.razor b/Features/Inventory/TransferIn/Components/_TransferInUpdateForm.razor new file mode 100644 index 0000000..0e985df --- /dev/null +++ b/Features/Inventory/TransferIn/Components/_TransferInUpdateForm.razor @@ -0,0 +1,263 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferIn.Cqrs +@using Indotalent.Features.Inventory.TransferIn.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject TransferInService TransferInService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Transfer In + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Receive Date + + + + + Ref Transfer Out + + @foreach (var item in _lookup.TransferOuts) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_TransferInItemDataTable Items="_items" TransferInId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total SKUs + @_items.Count +
+
+ Total Qty Received + @_items.Sum(x => x.Movement) +
+ +
+ Ref Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdateTransferInRequest 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 UpdateTransferInValidator _validator = new(); + private UpdateTransferInRequest _model = new(); + private List _items = new(); + private TransferInLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await TransferInService.GetTransferInLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await TransferInService.GetTransferInByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 TransferInService.GetTransferInByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = TransferInPdfGenerator.Generate(response.Value); + var fileName = $"TransferIn_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await TransferInService.UpdateTransferInAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/TransferIn/Cqrs/CreateTransferInHandler.cs b/Features/Inventory/TransferIn/Cqrs/CreateTransferInHandler.cs new file mode 100644 index 0000000..23874d4 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/CreateTransferInHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class CreateTransferInRequest +{ + public DateTime? TransferReceiveDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? TransferOutId { get; set; } +} + +public class CreateTransferInResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateTransferInCommand(CreateTransferInRequest Data) : IRequest; + +public class CreateTransferInHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateTransferInHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTransferInCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.TransferIn); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.TransferIn + { + AutoNumber = autoNo, + TransferReceiveDate = request.Data.TransferReceiveDate, + Status = request.Data.Status, + Description = request.Data.Description, + TransferOutId = request.Data.TransferOutId + }; + + _context.TransferIn.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTransferInResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/CreateTransferInItemHandler.cs b/Features/Inventory/TransferIn/Cqrs/CreateTransferInItemHandler.cs new file mode 100644 index 0000000..7791525 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/CreateTransferInItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class CreateTransferInItemRequest +{ + public string? TransferInId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateTransferInItemCommand(CreateTransferInItemRequest Data) : IRequest; + +public class CreateTransferInItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateTransferInItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTransferInItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.TransferIn + .AsNoTracking() + .Include(x => x.TransferOut) + .FirstOrDefaultAsync(x => x.Id == request.Data.TransferInId, cancellationToken); + + if (master == null || master.TransferOut == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.TransferIn), + ModuleCode = "TO-IN", + ModuleNumber = master.AutoNumber, + MovementDate = master.TransferReceiveDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = master.TransferOut.WarehouseToId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/CreateTransferInValidator.cs b/Features/Inventory/TransferIn/Cqrs/CreateTransferInValidator.cs new file mode 100644 index 0000000..4ce4cce --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/CreateTransferInValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class CreateTransferInValidator : AbstractValidator +{ + public CreateTransferInValidator() + { + RuleFor(x => x.TransferReceiveDate).NotEmpty().WithMessage("Receive Date is required"); + RuleFor(x => x.TransferOutId).NotEmpty().WithMessage("Transfer Out reference is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/DeleteTransferInByIdHandler.cs b/Features/Inventory/TransferIn/Cqrs/DeleteTransferInByIdHandler.cs new file mode 100644 index 0000000..73c200b --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/DeleteTransferInByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public record DeleteTransferInByIdCommand(string Id) : IRequest; + +public class DeleteTransferInByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteTransferInByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTransferInByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TransferIn + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.TransferIn)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.TransferIn.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/DeleteTransferInItemHandler.cs b/Features/Inventory/TransferIn/Cqrs/DeleteTransferInItemHandler.cs new file mode 100644 index 0000000..9a1440b --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/DeleteTransferInItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public record DeleteTransferInItemCommand(string Id) : IRequest; + +public class DeleteTransferInItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteTransferInItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTransferInItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/GetTransferInByIdHandler.cs b/Features/Inventory/TransferIn/Cqrs/GetTransferInByIdHandler.cs new file mode 100644 index 0000000..57b712d --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/GetTransferInByIdHandler.cs @@ -0,0 +1,78 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class TransferInItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public double? Movement { get; set; } +} + +public class GetTransferInByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReceiveDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? TransferOutId { get; set; } + public string? TransferOutNumber { get; set; } + public string? WarehouseToName { 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 GetTransferInByIdQuery(string Id) : IRequest; + +public class GetTransferInByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetTransferInByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTransferInByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.TransferIn + .AsNoTracking() + .Include(x => x.TransferOut) + .ThenInclude(x => x!.WarehouseTo) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.TransferIn)) + .Select(x => new TransferInItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + Movement = x.Movement + }).ToListAsync(cancellationToken); + + return new GetTransferInByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + TransferReceiveDate = master.TransferReceiveDate, + Status = master.Status, + Description = master.Description, + TransferOutId = master.TransferOutId, + TransferOutNumber = master.TransferOut?.AutoNumber, + WarehouseToName = master.TransferOut?.WarehouseTo?.Name, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/GetTransferInListHandler.cs b/Features/Inventory/TransferIn/Cqrs/GetTransferInListHandler.cs new file mode 100644 index 0000000..f358eb2 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/GetTransferInListHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class GetTransferInListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReceiveDate { get; set; } + public string? TransferOutNumber { get; set; } + public string? WarehouseToName { get; set; } + public Data.Enums.TransferStatus Status { get; set; } +} + +public record GetTransferInListQuery() : IRequest>; + +public class GetTransferInListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetTransferInListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTransferInListQuery request, CancellationToken cancellationToken) + { + return await _context.TransferIn + .AsNoTracking() + .Include(x => x.TransferOut) + .ThenInclude(x => x!.WarehouseTo) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetTransferInListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + TransferReceiveDate = x.TransferReceiveDate, + TransferOutNumber = x.TransferOut!.AutoNumber, + WarehouseToName = x.TransferOut!.WarehouseTo!.Name, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/GetTransferInLookupHandler.cs b/Features/Inventory/TransferIn/Cqrs/GetTransferInLookupHandler.cs new file mode 100644 index 0000000..c6be8f3 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/GetTransferInLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class TransferInLookupResponse +{ + public List TransferOuts { 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 GetTransferInLookupQuery() : IRequest; + +public class GetTransferInLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetTransferInLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTransferInLookupQuery request, CancellationToken cancellationToken) + { + var response = new TransferInLookupResponse(); + + response.TransferOuts = await _context.TransferOut.AsNoTracking() + .Where(x => x.Status >= TransferStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(TransferStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/UpdateTransferInHandler.cs b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInHandler.cs new file mode 100644 index 0000000..beb6616 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class UpdateTransferInRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReceiveDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? TransferOutId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateTransferInCommand(UpdateTransferInRequest Data) : IRequest; + +public class UpdateTransferInHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateTransferInHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTransferInCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TransferIn + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.TransferReceiveDate = request.Data.TransferReceiveDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.TransferOutId = request.Data.TransferOutId; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.TransferIn)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.TransferReceiveDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/UpdateTransferInItemHandler.cs b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInItemHandler.cs new file mode 100644 index 0000000..7bfa718 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInItemHandler.cs @@ -0,0 +1,36 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class UpdateTransferInItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateTransferInItemCommand(UpdateTransferInItemRequest Data) : IRequest; + +public class UpdateTransferInItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateTransferInItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTransferInItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/Cqrs/UpdateTransferInValidator.cs b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInValidator.cs new file mode 100644 index 0000000..1103625 --- /dev/null +++ b/Features/Inventory/TransferIn/Cqrs/UpdateTransferInValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.TransferIn.Cqrs; + +public class UpdateTransferInValidator : AbstractValidator +{ + public UpdateTransferInValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.TransferReceiveDate).NotEmpty().WithMessage("Receive Date is required"); + RuleFor(x => x.TransferOutId).NotEmpty().WithMessage("Transfer Out reference is required"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/TransferInEndpoint.cs b/Features/Inventory/TransferIn/TransferInEndpoint.cs new file mode 100644 index 0000000..ff41ed6 --- /dev/null +++ b/Features/Inventory/TransferIn/TransferInEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.TransferIn.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.TransferIn; + +public static class TransferInEndpoint +{ + public static void MapTransferInEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/transfer-in").WithTags("TransferIns") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTransferInListQuery()); + return result.ToApiResponse("Transfer in list retrieved successfully"); + }) + .WithName("GetTransferInList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTransferInByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Transfer in detail retrieved successfully" + : $"Transfer in with ID {id} not found"); + }) + .WithName("GetTransferInById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTransferInLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetTransferInLookup"); + + group.MapPost("/", async (CreateTransferInRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTransferInCommand(request)); + return result.ToApiResponse("Transfer in has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTransferIn"); + + group.MapPost("/update", async (UpdateTransferInRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTransferInCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Transfer in not found."); + } + return result.ToApiResponse("Transfer in has been updated successfully"); + }) + .WithName("UpdateTransferIn"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTransferInByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Transfer in not found."); + } + return true.ToApiResponse("Transfer in has been deleted successfully"); + }) + .WithName("DeleteTransferInById"); + + group.MapPost("/transfer-in-item", async (CreateTransferInItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTransferInItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateTransferInItem"); + + group.MapPost("/transfer-in-item/update", async (UpdateTransferInItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTransferInItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateTransferInItem"); + + group.MapPost("/transfer-in-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTransferInItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteTransferInItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferIn/TransferInPdfGenerator.cs b/Features/Inventory/TransferIn/TransferInPdfGenerator.cs new file mode 100644 index 0000000..59d6eb0 --- /dev/null +++ b/Features/Inventory/TransferIn/TransferInPdfGenerator.cs @@ -0,0 +1,123 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.TransferIn.Cqrs; + +namespace Indotalent.Features.Inventory.TransferIn; + +public class TransferInPdfGenerator +{ + public static byte[] Generate(GetTransferInByIdResponse 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("TRANSFER IN") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("STOCK RECEIVE").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.TransferReceiveDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Ref: {data.TransferOutNumber}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("DESTINATION WAREHOUSE").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.WarehouseToName).Bold(); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("STATUS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.Status.ToString().ToUpper()).Bold().FontColor(primaryBlue); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(6); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Description"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty Received"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(30).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(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Received By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse Staff )").FontSize(7); + }); + }); + }); + + 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/Inventory/TransferIn/TransferInService.cs b/Features/Inventory/TransferIn/TransferInService.cs new file mode 100644 index 0000000..8a6bb0b --- /dev/null +++ b/Features/Inventory/TransferIn/TransferInService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.TransferIn.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.TransferIn; + +public class TransferInService : BaseService +{ + private readonly RestClient _client; + + public TransferInService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTransferInListAsync() + { + var request = new RestRequest("api/transfer-in", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTransferInByIdAsync(string id) + { + var request = new RestRequest($"api/transfer-in/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetTransferInLookupAsync() + { + var request = new RestRequest("api/transfer-in/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTransferInAsync(CreateTransferInRequest data) + { + var request = new RestRequest("api/transfer-in", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTransferInAsync(UpdateTransferInRequest data) + { + var request = new RestRequest("api/transfer-in/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTransferInByIdAsync(string id) + { + var request = new RestRequest($"api/transfer-in/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateTransferInItemAsync(CreateTransferInItemRequest data) + { + var request = new RestRequest("api/transfer-in/transfer-in-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTransferInItemAsync(UpdateTransferInItemRequest data) + { + var request = new RestRequest("api/transfer-in/transfer-in-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTransferInItemAsync(string id) + { + var request = new RestRequest($"api/transfer-in/transfer-in-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/Inventory/TransferOut/Components/TransferOutPage.razor b/Features/Inventory/TransferOut/Components/TransferOutPage.razor new file mode 100644 index 0000000..35ba965 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/TransferOutPage.razor @@ -0,0 +1,51 @@ +@page "/inventory/transfer-out" +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using Indotalent.Features.Inventory.TransferOut.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TransferOutCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TransferOutUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_TransferOutDataTable 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 UpdateTransferOutRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTransferOutRequest 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/TransferOut/Components/_TransferOutCreateForm.razor b/Features/Inventory/TransferOut/Components/_TransferOutCreateForm.razor new file mode 100644 index 0000000..c476a75 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutCreateForm.razor @@ -0,0 +1,142 @@ +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TransferOutService TransferOutService +@inject ISnackbar Snackbar + + +
+ +
+ Add Transfer Out + Release stock for warehouse-to-warehouse transfer. +
+
+
+ + + + + Basic Information + + + Release Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + From Warehouse (Source) + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + To Warehouse (Destination) + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Description / Internal Notes + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Transfer Out + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTransferOutValidator _validator = new(); + private CreateTransferOutRequest _model = new() { TransferReleaseDate = DateTime.Today }; + private TransferOutLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferOutService.GetTransferOutLookupAsync(); + 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 TransferOutService.CreateTransferOutAsync(_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/Inventory/TransferOut/Components/_TransferOutDataTable.razor b/Features/Inventory/TransferOut/Components/_TransferOutDataTable.razor new file mode 100644 index 0000000..702cbeb --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutDataTable.razor @@ -0,0 +1,387 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferOut +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TransferOutService TransferOutService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Transfer Out + Manage stock releases from your warehouses. +
+
+ + / + Inventory + / + Transfer Out +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Transfer Out + + } +
+
+ + + + + + Number + + + Release Date + + + From Warehouse + + + To Warehouse + + + Status + + + + + + + @context.AutoNumber + @context.TransferReleaseDate?.ToString("yyyy-MM-dd") + @context.WarehouseFromName + @context.WarehouseToName + + @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 GetTransferOutListResponse? _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 TransferOutService.GetTransferOutListAsync(); + 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.WarehouseFromName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.WarehouseToName?.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("TransferOuts"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Release Date"; + worksheet.Cell(currentRow, 3).Value = "From Warehouse"; + worksheet.Cell(currentRow, 4).Value = "To Warehouse"; + 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.TransferReleaseDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.WarehouseFromName; + worksheet.Cell(currentRow, 4).Value = item.WarehouseToName; + worksheet.Cell(currentRow, 5).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", "TransferOut_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 TransferOutService.GetTransferOutByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateTransferOutRequest + { + Id = detail.Id, + TransferReleaseDate = detail.TransferReleaseDate, + Status = detail.Status, + Description = detail.Description, + WarehouseFromId = detail.WarehouseFromId, + WarehouseToId = detail.WarehouseToId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 TransferOutService.DeleteTransferOutByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Components/_TransferOutItemCreateForm.razor b/Features/Inventory/TransferOut/Components/_TransferOutItemCreateForm.razor new file mode 100644 index 0000000..36e17a5 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutItemCreateForm.razor @@ -0,0 +1,78 @@ +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using MudBlazor +@inject TransferOutService TransferOutService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Transfer Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Line + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string TransferOutId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateTransferOutItemRequest _model = new() { Movement = 1 }; + private TransferOutLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferOutService.GetTransferOutLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.TransferOutId = TransferOutId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TransferOutService.CreateTransferOutItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Components/_TransferOutItemDataTable.razor b/Features/Inventory/TransferOut/Components/_TransferOutItemDataTable.razor new file mode 100644 index 0000000..2b342e2 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutItemDataTable.razor @@ -0,0 +1,88 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferOut +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject TransferOutService TransferOutService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Items to Transfer + @if (!ReadOnly) + { + + Add Line Item + + } +
+ + + + Product + Quantity Released + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string TransferOutId { 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 { ["TransferOutId"] = TransferOutId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TransferOutItemCreateForm>("Add Item Line", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(TransferOutItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TransferOutItemUpdateForm>("Edit Item Line", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(TransferOutItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this product from the transfer?" }; + 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 TransferOutService.DeleteTransferOutItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Components/_TransferOutItemUpdateForm.razor b/Features/Inventory/TransferOut/Components/_TransferOutItemUpdateForm.razor new file mode 100644 index 0000000..fa8dc14 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutItemUpdateForm.razor @@ -0,0 +1,81 @@ +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using MudBlazor +@inject TransferOutService TransferOutService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Transfer Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public TransferOutItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateTransferOutItemRequest _model = new(); + private TransferOutLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await TransferOutService.GetTransferOutLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TransferOutService.UpdateTransferOutItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Components/_TransferOutUpdateForm.razor b/Features/Inventory/TransferOut/Components/_TransferOutUpdateForm.razor new file mode 100644 index 0000000..062e282 --- /dev/null +++ b/Features/Inventory/TransferOut/Components/_TransferOutUpdateForm.razor @@ -0,0 +1,276 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Inventory.TransferOut.Cqrs +@using Indotalent.Features.Inventory.TransferOut.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject TransferOutService TransferOutService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Transfer Out + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Release Date + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + From Warehouse (Source) + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + To Warehouse (Destination) + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + + Description / Internal Notes + + + + + <_TransferOutItemDataTable Items="_items" TransferOutId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total SKUs + @_items.Count +
+
+ Total Quantity Released + @_items.Sum(x => x.Movement) +
+ +
+ Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdateTransferOutRequest 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 UpdateTransferOutValidator _validator = new(); + private UpdateTransferOutRequest _model = new(); + private List _items = new(); + private TransferOutLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await TransferOutService.GetTransferOutLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await TransferOutService.GetTransferOutByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 TransferOutService.GetTransferOutByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = TransferOutPdfGenerator.Generate(response.Value); + var fileName = $"TransferOut_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await TransferOutService.UpdateTransferOutAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/Inventory/TransferOut/Cqrs/CreateTransferOutHandler.cs b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutHandler.cs new file mode 100644 index 0000000..cee552c --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class CreateTransferOutRequest +{ + public DateTime? TransferReleaseDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseFromId { get; set; } + public string? WarehouseToId { get; set; } +} + +public class CreateTransferOutResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateTransferOutCommand(CreateTransferOutRequest Data) : IRequest; + +public class CreateTransferOutHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateTransferOutHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTransferOutCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.TransferOut); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.TransferOut + { + AutoNumber = autoNo, + TransferReleaseDate = request.Data.TransferReleaseDate, + Status = request.Data.Status, + Description = request.Data.Description, + WarehouseFromId = request.Data.WarehouseFromId, + WarehouseToId = request.Data.WarehouseToId + }; + + _context.TransferOut.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTransferOutResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/CreateTransferOutItemHandler.cs b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutItemHandler.cs new file mode 100644 index 0000000..49bf7a1 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutItemHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class CreateTransferOutItemRequest +{ + public string? TransferOutId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateTransferOutItemCommand(CreateTransferOutItemRequest Data) : IRequest; + +public class CreateTransferOutItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateTransferOutItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTransferOutItemCommand request, CancellationToken cancellationToken) + { + var master = await _context.TransferOut + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.TransferOutId, cancellationToken); + + if (master == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNoIVT = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + ModuleId = master.Id, + ModuleName = nameof(Data.Entities.TransferOut), + ModuleCode = "TO-OUT", + ModuleNumber = master.AutoNumber, + MovementDate = master.TransferReleaseDate!.Value, + Status = (Data.Enums.InventoryTransactionStatus)master.Status, + AutoNumber = autoNoIVT, + WarehouseId = master.WarehouseFromId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/CreateTransferOutValidator.cs b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutValidator.cs new file mode 100644 index 0000000..ba90196 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/CreateTransferOutValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class CreateTransferOutValidator : AbstractValidator +{ + public CreateTransferOutValidator() + { + RuleFor(x => x.TransferReleaseDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.WarehouseFromId).NotEmpty().WithMessage("Source warehouse is required"); + RuleFor(x => x.WarehouseToId).NotEmpty().WithMessage("Destination warehouse is required") + .NotEqual(x => x.WarehouseFromId).WithMessage("Source and Destination warehouse cannot be the same"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutByIdHandler.cs b/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutByIdHandler.cs new file mode 100644 index 0000000..70a8b95 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public record DeleteTransferOutByIdCommand(string Id) : IRequest; + +public class DeleteTransferOutByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteTransferOutByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTransferOutByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TransferOut + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var items = await _context.InventoryTransaction + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.TransferOut)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(items); + _context.TransferOut.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutItemHandler.cs b/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutItemHandler.cs new file mode 100644 index 0000000..7c20d74 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/DeleteTransferOutItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public record DeleteTransferOutItemCommand(string Id) : IRequest; + +public class DeleteTransferOutItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteTransferOutItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTransferOutItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/GetTransferOutByIdHandler.cs b/Features/Inventory/TransferOut/Cqrs/GetTransferOutByIdHandler.cs new file mode 100644 index 0000000..79e9ef4 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/GetTransferOutByIdHandler.cs @@ -0,0 +1,80 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class TransferOutItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public double? Movement { get; set; } +} + +public class GetTransferOutByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReleaseDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseFromId { get; set; } + public string? WarehouseFromName { get; set; } + public string? WarehouseToId { get; set; } + public string? WarehouseToName { 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 GetTransferOutByIdQuery(string Id) : IRequest; + +public class GetTransferOutByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetTransferOutByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTransferOutByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.TransferOut + .AsNoTracking() + .Include(x => x.WarehouseFrom) + .Include(x => x.WarehouseTo) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var items = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.TransferOut)) + .Select(x => new TransferOutItemResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + Movement = x.Movement + }).ToListAsync(cancellationToken); + + return new GetTransferOutByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + TransferReleaseDate = master.TransferReleaseDate, + Status = master.Status, + Description = master.Description, + WarehouseFromId = master.WarehouseFromId, + WarehouseFromName = master.WarehouseFrom?.Name, + WarehouseToId = master.WarehouseToId, + WarehouseToName = master.WarehouseTo?.Name, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/GetTransferOutListHandler.cs b/Features/Inventory/TransferOut/Cqrs/GetTransferOutListHandler.cs new file mode 100644 index 0000000..c13343e --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/GetTransferOutListHandler.cs @@ -0,0 +1,42 @@ +using Indotalent.Features.Sales.SalesReturn.Cqrs; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class GetTransferOutListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReleaseDate { get; set; } + public string? WarehouseFromName { get; set; } + public string? WarehouseToName { get; set; } + public Data.Enums.TransferStatus Status { get; set; } +} + +public record GetTransferOutListQuery() : IRequest>; + +public class GetTransferOutListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetTransferOutListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTransferOutListQuery request, CancellationToken cancellationToken) + { + return await _context.TransferOut + .AsNoTracking() + .Include(x => x.WarehouseFrom) + .Include(x => x.WarehouseTo) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetTransferOutListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + TransferReleaseDate = x.TransferReleaseDate, + WarehouseFromName = x.WarehouseFrom!.Name, + WarehouseToName = x.WarehouseTo!.Name, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/GetTransferOutLookupHandler.cs b/Features/Inventory/TransferOut/Cqrs/GetTransferOutLookupHandler.cs new file mode 100644 index 0000000..9be9291 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/GetTransferOutLookupHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class TransferOutLookupResponse +{ + public List Warehouses { 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 GetTransferOutLookupQuery() : IRequest; + +public class GetTransferOutLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetTransferOutLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTransferOutLookupQuery request, CancellationToken cancellationToken) + { + var response = new TransferOutLookupResponse(); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(TransferStatus)) + .Cast() + .Select(x => new LookupStatusItem + { + Value = (int)x, + Name = x.GetDescription() + }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutHandler.cs b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutHandler.cs new file mode 100644 index 0000000..235c0da --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class UpdateTransferOutRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? TransferReleaseDate { get; set; } + public Data.Enums.TransferStatus Status { get; set; } + public string? Description { get; set; } + public string? WarehouseFromId { get; set; } + public string? WarehouseToId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateTransferOutCommand(UpdateTransferOutRequest Data) : IRequest; + +public class UpdateTransferOutHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateTransferOutHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTransferOutCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TransferOut + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.TransferReleaseDate = request.Data.TransferReleaseDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.WarehouseFromId = request.Data.WarehouseFromId; + entity.WarehouseToId = request.Data.WarehouseToId; + + var details = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.TransferOut)) + .ToListAsync(cancellationToken); + + foreach (var item in details) + { + item.MovementDate = entity.TransferReleaseDate ?? DateTime.Now; + item.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + item.WarehouseId = entity.WarehouseFromId; + item.ModuleNumber = entity.AutoNumber; + _context.CalculateInvenTrans(item); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutItemHandler.cs b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutItemHandler.cs new file mode 100644 index 0000000..e18c045 --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutItemHandler.cs @@ -0,0 +1,36 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class UpdateTransferOutItemRequest +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateTransferOutItemCommand(UpdateTransferOutItemRequest Data) : IRequest; + +public class UpdateTransferOutItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateTransferOutItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTransferOutItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutValidator.cs b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutValidator.cs new file mode 100644 index 0000000..d0acc3e --- /dev/null +++ b/Features/Inventory/TransferOut/Cqrs/UpdateTransferOutValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Inventory.TransferOut.Cqrs; + +public class UpdateTransferOutValidator : AbstractValidator +{ + public UpdateTransferOutValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.WarehouseFromId).NotEmpty().WithMessage("Source warehouse is required"); + RuleFor(x => x.WarehouseToId).NotEmpty().WithMessage("Destination warehouse is required") + .NotEqual(x => x.WarehouseFromId).WithMessage("Source and Destination warehouse cannot be the same"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/TransferOutEndpoint.cs b/Features/Inventory/TransferOut/TransferOutEndpoint.cs new file mode 100644 index 0000000..1caaf7e --- /dev/null +++ b/Features/Inventory/TransferOut/TransferOutEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Inventory.TransferOut.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Inventory.TransferOut; + +public static class TransferOutEndpoint +{ + public static void MapTransferOutEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/transfer-out").WithTags("TransferOuts") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTransferOutListQuery()); + return result.ToApiResponse("Transfer out list retrieved successfully"); + }) + .WithName("GetTransferOutList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTransferOutByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Transfer out detail retrieved successfully" + : $"Transfer out with ID {id} not found"); + }) + .WithName("GetTransferOutById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTransferOutLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetTransferOutLookup"); + + group.MapPost("/", async (CreateTransferOutRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTransferOutCommand(request)); + return result.ToApiResponse("Transfer out has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTransferOut"); + + group.MapPost("/update", async (UpdateTransferOutRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTransferOutCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Transfer out not found."); + } + return result.ToApiResponse("Transfer out has been updated successfully"); + }) + .WithName("UpdateTransferOut"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTransferOutByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Transfer out not found."); + } + return true.ToApiResponse("Transfer out has been deleted successfully"); + }) + .WithName("DeleteTransferOutById"); + + group.MapPost("/transfer-out-item", async (CreateTransferOutItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTransferOutItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateTransferOutItem"); + + group.MapPost("/transfer-out-item/update", async (UpdateTransferOutItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTransferOutItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateTransferOutItem"); + + group.MapPost("/transfer-out-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTransferOutItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteTransferOutItem"); + } +} \ No newline at end of file diff --git a/Features/Inventory/TransferOut/TransferOutPdfGenerator.cs b/Features/Inventory/TransferOut/TransferOutPdfGenerator.cs new file mode 100644 index 0000000..5b01629 --- /dev/null +++ b/Features/Inventory/TransferOut/TransferOutPdfGenerator.cs @@ -0,0 +1,123 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Inventory.TransferOut.Cqrs; + +namespace Indotalent.Features.Inventory.TransferOut; + +public class TransferOutPdfGenerator +{ + public static byte[] Generate(GetTransferOutByIdResponse 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("TRANSFER OUT") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("STOCK RELEASE").FontSize(11).ExtraBold(); + col.Item().Text($"Date: {data.TransferReleaseDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + col.Item().Text($"Status: {data.Status.ToString().ToUpper()}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("FROM WAREHOUSE").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.WarehouseFromName).Bold(); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("TO WAREHOUSE").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Text(data.WarehouseToName).Bold(); + }); + }); + + col.Item().PaddingTop(20).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(6); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Name"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(20).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(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse Manager )").FontSize(7); + }); + }); + }); + + 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/Inventory/TransferOut/TransferOutService.cs b/Features/Inventory/TransferOut/TransferOutService.cs new file mode 100644 index 0000000..9faa36c --- /dev/null +++ b/Features/Inventory/TransferOut/TransferOutService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Inventory.TransferOut.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Inventory.TransferOut; + +public class TransferOutService : BaseService +{ + private readonly RestClient _client; + + public TransferOutService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTransferOutListAsync() + { + var request = new RestRequest("api/transfer-out", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTransferOutByIdAsync(string id) + { + var request = new RestRequest($"api/transfer-out/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetTransferOutLookupAsync() + { + var request = new RestRequest("api/transfer-out/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTransferOutAsync(CreateTransferOutRequest data) + { + var request = new RestRequest("api/transfer-out", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTransferOutAsync(UpdateTransferOutRequest data) + { + var request = new RestRequest("api/transfer-out/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTransferOutByIdAsync(string id) + { + var request = new RestRequest($"api/transfer-out/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateTransferOutItemAsync(CreateTransferOutItemRequest data) + { + var request = new RestRequest("api/transfer-out/transfer-out-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTransferOutItemAsync(UpdateTransferOutItemRequest data) + { + var request = new RestRequest("api/transfer-out/transfer-out-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTransferOutItemAsync(string id) + { + var request = new RestRequest($"api/transfer-out/transfer-out-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/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..d7ccd7d --- /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..837bf03 --- /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..c564d38 --- /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..1ac5319 --- /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..17484a5 --- /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..d7dfc5b --- /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/Pipeline/Budget/BudgetEndpoint.cs b/Features/Pipeline/Budget/BudgetEndpoint.cs new file mode 100644 index 0000000..158407f --- /dev/null +++ b/Features/Pipeline/Budget/BudgetEndpoint.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.Budget.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.Budget; + +public static class BudgetEndpoint +{ + public static void MapBudgetEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/budget").WithTags("Budgets") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBudgetListQuery()); + return result.ToApiResponse("Budgets retrieved successfully"); + }) + .WithName("GetBudgetList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBudgetByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Budget detail retrieved successfully" + : $"Budget with ID {id} not found"); + }) + .WithName("GetBudgetById"); + + group.MapPost("/", async (CreateBudgetRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBudgetCommand(request)); + return result.ToApiResponse("Budget has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBudget"); + + group.MapPost("/update", async (UpdateBudgetRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBudgetCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The budget data could not be found."); + } + return result.ToApiResponse("Budget has been updated successfully"); + }) + .WithName("UpdateBudget"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBudgetByIdCommand(new DeleteBudgetByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The budget data could not be found."); + } + return true.ToApiResponse("Budget has been deleted successfully"); + }) + .WithName("DeleteBudgetById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBudgetLookupQuery()); + return result.ToApiResponse("Budget lookup data retrieved successfully"); + }) + .WithName("GetBudgetLookup"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/BudgetService.cs b/Features/Pipeline/Budget/BudgetService.cs new file mode 100644 index 0000000..c88b485 --- /dev/null +++ b/Features/Pipeline/Budget/BudgetService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.Budget.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.Budget; + +public class BudgetService : BaseService +{ + private readonly RestClient _client; + + public BudgetService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBudgetListAsync() + { + var request = new RestRequest("api/budget", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBudgetByIdAsync(string id) + { + var request = new RestRequest($"api/budget/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBudgetAsync(CreateBudgetRequest data) + { + var request = new RestRequest("api/budget", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBudgetByIdAsync(string id) + { + var request = new RestRequest($"api/budget/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateBudgetAsync(UpdateBudgetRequest data) + { + var request = new RestRequest("api/budget/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetBudgetLookupAsync() + { + var request = new RestRequest("api/budget/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Components/BudgetPage.razor b/Features/Pipeline/Budget/Components/BudgetPage.razor new file mode 100644 index 0000000..ac49820 --- /dev/null +++ b/Features/Pipeline/Budget/Components/BudgetPage.razor @@ -0,0 +1,53 @@ +@page "/pipeline/budget" +@using Indotalent.Features.Pipeline.Budget +@using Indotalent.Features.Pipeline.Budget.Cqrs +@using Indotalent.Features.Pipeline.Budget.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BudgetCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BudgetUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_BudgetDataTable 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 UpdateBudgetRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBudgetRequest 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/Pipeline/Budget/Components/_BudgetCreateForm.razor b/Features/Pipeline/Budget/Components/_BudgetCreateForm.razor new file mode 100644 index 0000000..7a0eb7c --- /dev/null +++ b/Features/Pipeline/Budget/Components/_BudgetCreateForm.razor @@ -0,0 +1,144 @@ +@using Indotalent.Features.Pipeline.Budget +@using Indotalent.Features.Pipeline.Budget.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BudgetService BudgetService +@inject ISnackbar Snackbar + + + +
+ Add New Budget + Allocate budget resources for your campaign. +
+
+ + + + + + Title + + + + + Campaign + + @foreach (var item in _lookup.Campaigns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Amount + + + + + Budget Date + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Budget + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBudgetValidator _validator = new(); + private CreateBudgetRequest _model = new(); + private BudgetLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await BudgetService.GetBudgetLookupAsync(); + if (response != null && response.IsSuccess) + { + _lookup = response.Value ?? new BudgetLookupResponse(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BudgetService.CreateBudgetAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Budget 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/Pipeline/Budget/Components/_BudgetDataTable.razor b/Features/Pipeline/Budget/Components/_BudgetDataTable.razor new file mode 100644 index 0000000..fd33cee --- /dev/null +++ b/Features/Pipeline/Budget/Components/_BudgetDataTable.razor @@ -0,0 +1,372 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Budget +@using Indotalent.Features.Pipeline.Budget.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BudgetService BudgetService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Budget Management + Monitor and control budget allocations for your campaigns. +
+
+ + / + Pipeline + / + Budget +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedBudget != null) + { + View + Edit + Remove + + } + else + { + + Add New Budget + + } +
+
+ + + + + + Number + + + Title + + Campaign + Amount + Status + + + + + + @context.AutoNumber + @context.Title + @context.CampaignTitle + @context.Amount?.ToString("N0") + @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 _budgets = new(); + private GetBudgetListResponse? _selectedBudget; + 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; + _selectedBudget = null; + StateHasChanged(); + try + { + var response = await BudgetService.GetBudgetListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _budgets = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _budgets; + return _budgets.Where(x => + (x.Title?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CampaignTitle?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedBudget = 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("Budgets"); + var currentRow = 1; + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Title"; + worksheet.Cell(currentRow, 3).Value = "Campaign"; + worksheet.Cell(currentRow, 4).Value = "Amount"; + 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.Title; + worksheet.Cell(currentRow, 3).Value = item.CampaignTitle; + worksheet.Cell(currentRow, 4).Value = item.Amount; + worksheet.Cell(currentRow, 5).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", "Budget_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; + _selectedBudget = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedBudget = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedBudget == null) return; + var request = await MapToUpdateRequest(_selectedBudget.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedBudget == null) return; + var request = await MapToUpdateRequest(_selectedBudget.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await BudgetService.GetBudgetByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateBudgetRequest + { + Id = detail.Id, + Title = detail.Title, + Description = detail.Description, + Amount = detail.Amount, + BudgetDate = detail.BudgetDate, + Status = detail.Status, + CampaignId = detail.CampaignId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedBudget == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedBudget.Title } }; + 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 BudgetService.DeleteBudgetByIdAsync(_selectedBudget.Id!); + if (isSuccess) + { + _selectedBudget = null; + await LoadData(); + Snackbar.Add("Budget deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Components/_BudgetUpdateForm.razor b/Features/Pipeline/Budget/Components/_BudgetUpdateForm.razor new file mode 100644 index 0000000..52a2995 --- /dev/null +++ b/Features/Pipeline/Budget/Components/_BudgetUpdateForm.razor @@ -0,0 +1,195 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Budget +@using Indotalent.Features.Pipeline.Budget.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BudgetService BudgetService +@inject ISnackbar Snackbar + + + +
+ @(ReadOnly ? "Budget Details" : "Edit Budget") + @(ReadOnly ? "Viewing allocated resources." : "Modify existing budget allocation.") +
+
+ + + + + + Title + + + + + Campaign + + @foreach (var item in _lookup.Campaigns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Amount + + + + + Budget Date + + + + + 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 UpdateBudgetRequest 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 UpdateBudgetValidator _validator = new(); + private UpdateBudgetRequest _model = new(); + private BudgetLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await BudgetService.GetBudgetLookupAsync(); + if (response != null && response.IsSuccess) + { + _lookup = response.Value ?? new BudgetLookupResponse(); + } + + _model = new UpdateBudgetRequest + { + Id = Data.Id, + Title = Data.Title, + Description = Data.Description, + Amount = Data.Amount, + BudgetDate = Data.BudgetDate, + Status = Data.Status, + CampaignId = Data.CampaignId, + 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 BudgetService.UpdateBudgetAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Budget 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/Pipeline/Budget/Cqrs/CreateBudgetHandler.cs b/Features/Pipeline/Budget/Cqrs/CreateBudgetHandler.cs new file mode 100644 index 0000000..aa2cb2f --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/CreateBudgetHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class CreateBudgetRequest +{ + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } = DateTime.Today; + public BudgetStatus Status { get; set; } = BudgetStatus.Draft; + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } +} + +public class CreateBudgetResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateBudgetCommand(CreateBudgetRequest Data) : IRequest; + +public class CreateBudgetHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateBudgetHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBudgetCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Budget); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Budget + { + AutoNumber = autoNo, + Title = request.Data.Title, + Description = request.Data.Description, + BudgetDate = request.Data.BudgetDate, + Status = request.Data.Status, + Amount = request.Data.Amount, + CampaignId = request.Data.CampaignId + }; + + _context.Budget.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBudgetResponse { Id = entity.Id, AutoNumber = entity.AutoNumber }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/CreateBudgetValidator.cs b/Features/Pipeline/Budget/Cqrs/CreateBudgetValidator.cs new file mode 100644 index 0000000..a833e5e --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/CreateBudgetValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class CreateBudgetValidator : AbstractValidator +{ + public CreateBudgetValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Title is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CampaignId) + .NotEmpty().WithMessage("Campaign is required"); + + RuleFor(x => x.Amount) + .GreaterThanOrEqualTo(0).WithMessage("Amount must be zero or greater"); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((CreateBudgetRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/DeleteBudgetByIdHandler.cs b/Features/Pipeline/Budget/Cqrs/DeleteBudgetByIdHandler.cs new file mode 100644 index 0000000..309c6c2 --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/DeleteBudgetByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public record DeleteBudgetByIdRequest(string Id); + +public record DeleteBudgetByIdCommand(DeleteBudgetByIdRequest Data) : IRequest; + +public class DeleteBudgetByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteBudgetByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBudgetByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Budget + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Budget.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/GetBudgetByIdHandler.cs b/Features/Pipeline/Budget/Cqrs/GetBudgetByIdHandler.cs new file mode 100644 index 0000000..febe589 --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/GetBudgetByIdHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class GetBudgetByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } + public BudgetStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBudgetByIdQuery(string Id) : IRequest; + +public class GetBudgetByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBudgetByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBudgetByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Budget + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetBudgetByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + Description = x.Description, + BudgetDate = x.BudgetDate, + Status = x.Status, + Amount = x.Amount, + CampaignId = x.CampaignId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/GetBudgetListHandler.cs b/Features/Pipeline/Budget/Cqrs/GetBudgetListHandler.cs new file mode 100644 index 0000000..8c8080c --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/GetBudgetListHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class GetBudgetListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? CampaignTitle { get; set; } + public decimal? Amount { get; set; } + public DateTime? BudgetDate { get; set; } + public BudgetStatus Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } +} + +public record GetBudgetListQuery() : IRequest>; + +public class GetBudgetListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBudgetListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBudgetListQuery request, CancellationToken cancellationToken) + { + return await _context.Budget + .AsNoTracking() + .Include(x => x.Campaign) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetBudgetListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + CampaignTitle = x.Campaign != null ? x.Campaign.Title : string.Empty, + Amount = x.Amount, + BudgetDate = x.BudgetDate, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/GetBudgetLookupHandler.cs b/Features/Pipeline/Budget/Cqrs/GetBudgetLookupHandler.cs new file mode 100644 index 0000000..0b9b275 --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/GetBudgetLookupHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class BudgetLookupResponse +{ + public List Campaigns { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetBudgetLookupQuery() : IRequest; + +public class GetBudgetLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBudgetLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBudgetLookupQuery request, CancellationToken cancellationToken) + { + var response = new BudgetLookupResponse(); + + response.Campaigns = await _context.Campaign + .AsNoTracking() + .OrderBy(x => x.Title) + .Select(x => new LookupItem { Id = x.Id, Name = x.Title }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(BudgetStatus)) + .Cast() + .Select(x => new LookupItem + { + Value = (int)x, + Name = x.ToString() + }) + .ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/UpdateBudgetHandler.cs b/Features/Pipeline/Budget/Cqrs/UpdateBudgetHandler.cs new file mode 100644 index 0000000..ca03031 --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/UpdateBudgetHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class UpdateBudgetRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } + public BudgetStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateBudgetResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBudgetCommand(UpdateBudgetRequest Data) : IRequest; + +public class UpdateBudgetHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateBudgetHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBudgetCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Budget + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateBudgetResponse { Success = false }; + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.BudgetDate = request.Data.BudgetDate; + entity.Status = request.Data.Status; + entity.Amount = request.Data.Amount; + entity.CampaignId = request.Data.CampaignId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBudgetResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Budget/Cqrs/UpdateBudgetValidator.cs b/Features/Pipeline/Budget/Cqrs/UpdateBudgetValidator.cs new file mode 100644 index 0000000..5d863ce --- /dev/null +++ b/Features/Pipeline/Budget/Cqrs/UpdateBudgetValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Budget.Cqrs; + +public class UpdateBudgetValidator : AbstractValidator +{ + public UpdateBudgetValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Title is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CampaignId) + .NotEmpty().WithMessage("Campaign is required"); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((UpdateBudgetRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/CampaignEndpoint.cs b/Features/Pipeline/Campaign/CampaignEndpoint.cs new file mode 100644 index 0000000..b152489 --- /dev/null +++ b/Features/Pipeline/Campaign/CampaignEndpoint.cs @@ -0,0 +1,102 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.Campaign.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.Campaign; + +public static class CampaignEndpoint +{ + public static void MapCampaignEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/campaign").WithTags("Campaign") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCampaignLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetCampaignLookup"); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCampaignListQuery()); + return result.ToApiResponse("Campaign list retrieved successfully"); + }) + .WithName("GetCampaignList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCampaignByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Campaign detail retrieved successfully" + : "Campaign not found"); + }) + .WithName("GetCampaignById"); + + group.MapPost("/", async (CreateCampaignRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCampaignCommand(request)); + return result.ToApiResponse("Campaign created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCampaign"); + + group.MapPost("/update", async (UpdateCampaignRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCampaignCommand(request)); + return result.ToApiResponse("Campaign updated successfully"); + }) + .WithName("UpdateCampaign"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCampaignByIdCommand(id)); + return result.ToApiResponse("Campaign deleted successfully"); + }) + .WithName("DeleteCampaign"); + + group.MapPost("/budget", async (CreateBudgetRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBudgetCommand(request)); + return result.ToApiResponse("Budget added successfully"); + }) + .WithName("AddCampaignBudget"); + + group.MapPost("/budget/update", async (UpdateBudgetRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBudgetCommand(request)); + return result.ToApiResponse("Budget updated successfully"); + }) + .WithName("UpdateCampaignBudget"); + + group.MapPost("/budget/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBudgetCommand(id)); + return result.ToApiResponse("Budget deleted successfully"); + }) + .WithName("DeleteCampaignBudget"); + + group.MapPost("/expense", async (CreateExpenseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateExpenseCommand(request)); + return result.ToApiResponse("Expense added successfully"); + }) + .WithName("AddCampaignExpense"); + + group.MapPost("/expense/update", async (UpdateExpenseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateExpenseCommand(request)); + return result.ToApiResponse("Expense updated successfully"); + }) + .WithName("UpdateCampaignExpense"); + + group.MapPost("/expense/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteExpenseCommand(id)); + return result.ToApiResponse("Expense deleted successfully"); + }) + .WithName("DeleteCampaignExpense"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/CampaingService.cs b/Features/Pipeline/Campaign/CampaingService.cs new file mode 100644 index 0000000..55592a7 --- /dev/null +++ b/Features/Pipeline/Campaign/CampaingService.cs @@ -0,0 +1,107 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.Campaign.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.Campaign; + +public class CampaignService : BaseService +{ + private readonly RestClient _client; + + public CampaignService( + 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/campaign/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task>?> GetCampaignListAsync() + { + var request = new RestRequest("api/campaign", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCampaignByIdAsync(string id) + { + var request = new RestRequest($"api/campaign/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCampaignAsync(CreateCampaignRequest data) + { + var request = new RestRequest("api/campaign", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateCampaignAsync(UpdateCampaignRequest data) + { + var request = new RestRequest("api/campaign/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCampaignAsync(string id) + { + var request = new RestRequest($"api/campaign/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> AddBudgetAsync(CreateBudgetRequest data) + { + var request = new RestRequest("api/campaign/budget", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateBudgetAsync(UpdateBudgetRequest data) + { + var request = new RestRequest("api/campaign/budget/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBudgetAsync(string id) + { + var request = new RestRequest($"api/campaign/budget/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> AddExpenseAsync(CreateExpenseRequest data) + { + var request = new RestRequest("api/campaign/expense", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateExpenseAsync(UpdateExpenseRequest data) + { + var request = new RestRequest("api/campaign/expense/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteExpenseAsync(string id) + { + var request = new RestRequest($"api/campaign/expense/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/CampaignPage.razor b/Features/Pipeline/Campaign/Components/CampaignPage.razor new file mode 100644 index 0000000..c7c4718 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/CampaignPage.razor @@ -0,0 +1,28 @@ +@page "/pipeline/campaign" +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Indotalent.Features.Pipeline.Campaign.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CampaignCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CampaignUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_CampaignDataTable 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 UpdateCampaignRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateCampaignRequest 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/Pipeline/Campaign/Components/_BudgetCreateForm.razor b/Features/Pipeline/Campaign/Components/_BudgetCreateForm.razor new file mode 100644 index 0000000..f61f8f0 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_BudgetCreateForm.razor @@ -0,0 +1,91 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using MudBlazor +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + + + + + + Budget Title + + + + Amount + + + + Date + + + + Status + + @foreach (var item in Lookup.BudgetStatuses) + { + @item.Name + } + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Budget + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string CampaignId { get; set; } = string.Empty; + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + private MudForm _form = default!; + private CreateBudgetRequest _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.CampaignId = CampaignId; + try + { + var response = await CampaignService.AddBudgetAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Budget added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_BudgetDataTable.razor b/Features/Pipeline/Campaign/Components/_BudgetDataTable.razor new file mode 100644 index 0000000..5a7f7d6 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_BudgetDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Indotalent.Features.Root.Shared +@using MudBlazor +@inject CampaignService CampaignService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Campaign Budgets + @if (!ReadOnly) + { + Add Budget + } +
+ + + No + Title + Amount + @if (!ReadOnly) + { + Actions + } + + + @budgetContext.AutoNumber + @budgetContext.Title + @budgetContext.Amount?.ToString("N0") + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string CampaignId { get; set; } = string.Empty; + [Parameter] public List Items { get; set; } = new(); + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var p = new DialogParameters { ["CampaignId"] = CampaignId, ["Lookup"] = Lookup }; + var d = await DialogService.ShowAsync<_BudgetCreateForm>("Add Budget", p, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await d.Result; + if (result != null && !result.Canceled) + { + await OnChanged.InvokeAsync(); + } + } + + private async Task OnEditClick(CampaignBudgetResponse item) + { + var p = new DialogParameters { ["Data"] = item, ["Lookup"] = Lookup }; + var d = await DialogService.ShowAsync<_BudgetUpdateForm>("Edit Budget", p, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await d.Result; + if (result != null && !result.Canceled) + { + await OnChanged.InvokeAsync(); + } + } + + private async Task OnDeleteClick(CampaignBudgetResponse item) + { + var p = new DialogParameters { ["ContentText"] = $"Are you sure you want to delete budget {item.AutoNumber}?" }; + var d = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", p, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await d.Result; + + if (result != null && !result.Canceled) + { + if (await CampaignService.DeleteBudgetAsync(item.Id!)) + { + Snackbar.Add("Budget deleted successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_BudgetUpdateForm.razor b/Features/Pipeline/Campaign/Components/_BudgetUpdateForm.razor new file mode 100644 index 0000000..afb7ded --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_BudgetUpdateForm.razor @@ -0,0 +1,100 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using MudBlazor +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + + + + + + Budget Title + + + + Amount + + + + Date + + + + Status + + @foreach (var item in Lookup.BudgetStatuses) + { + @item.Name + } + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public CampaignBudgetResponse Data { get; set; } = new(); + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + private MudForm _form = default!; + private UpdateBudgetRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Title = Data.Title; + _model.Amount = Data.Amount; + _model.BudgetDate = Data.BudgetDate; + _model.Status = Data.Status; + _model.Description = Data.Description; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await CampaignService.UpdateBudgetAsync(_model); + await Task.Delay(500); + + if (res != null && res.IsSuccess) + { + Snackbar.Add("Budget updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_CampaignCreateForm.razor b/Features/Pipeline/Campaign/Components/_CampaignCreateForm.razor new file mode 100644 index 0000000..9c138e1 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_CampaignCreateForm.razor @@ -0,0 +1,123 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Campaign + Initiate a new marketing campaign and track progress. +
+
+
+ + + + + + Campaign Title + + + + + Sales Team + + @foreach (var item in _lookup.SalesTeams) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Start Date + + + + + Finish Date + + + + + Target Revenue + + + + + Description + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Campaign + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCampaignValidator _validator = new(); + private CreateCampaignRequest _model = new() { Status = Indotalent.Data.Enums.CampaignStatus.Draft }; + private CampaignLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await CampaignService.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 CampaignService.CreateCampaignAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Campaign 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/Pipeline/Campaign/Components/_CampaignDataTable.razor b/Features/Pipeline/Campaign/Components/_CampaignDataTable.razor new file mode 100644 index 0000000..fcbe8e8 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_CampaignDataTable.razor @@ -0,0 +1,260 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Campaign +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CampaignService CampaignService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Campaign Management + Manage marketing campaigns, budgets, and expenses. +
+
+ + / + Pipeline + / + Campaign +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) { } else { Excel } + + + + @if (_isRefreshing) { + Refreshing... } else { Refresh } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New Campaign + + } +
+
+ + + + + + No + + + Title + + + Sales Team + + + Target Revenue + + + Status + + + + + @context.AutoNumber + @context.Title + @context.SalesTeamName + @context.TargetRevenueAmount?.ToString("N0") + + @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 GetCampaignListResponse? _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 CampaignService.GetCampaignListAsync(); + 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.SalesTeamName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private Color GetStatusColor(string? status) => status switch { + "Finished" => Color.Success, "OnProgress" => Color.Info, "Confirmed" => Color.Primary, "Cancelled" => Color.Error, "Draft" => Color.Default, _ => Color.Default + }; + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Campaigns"); + worksheet.Cell(1, 1).Value = "No"; worksheet.Cell(1, 2).Value = "Title"; + worksheet.Cell(1, 3).Value = "Sales Team"; worksheet.Cell(1, 4).Value = "Target Revenue"; + 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.SalesTeamName; worksheet.Cell(row, 4).Value = item.TargetRevenueAmount; + 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", "Campaign_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 CampaignService.GetCampaignByIdAsync(id); + if (response != null && response.IsSuccess) + { + var d = response.Value!; + return new UpdateCampaignRequest { + Id = d.Id, Title = d.Title, Description = d.Description, TargetRevenueAmount = d.TargetRevenueAmount, + CampaignDateStart = d.CampaignDateStart, CampaignDateFinish = d.CampaignDateFinish, + Status = d.Status, SalesTeamId = d.SalesTeamId, + 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 CampaignService.DeleteCampaignAsync(_selectedItem.Id!)) { await LoadData(); Snackbar.Add("Deleted successfully", Severity.Success); } } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_CampaignUpdateForm.razor b/Features/Pipeline/Campaign/Components/_CampaignUpdateForm.razor new file mode 100644 index 0000000..dbe6642 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_CampaignUpdateForm.razor @@ -0,0 +1,210 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Indotalent.Features.Pipeline.Campaign.Components +@using MudBlazor +@using Indotalent.ConfigBackEnd.Extensions +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Campaign Details" : "Edit Campaign") + Manage campaign specifications and financial tracking. +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Campaign Title + + + + Sales Team + + @foreach (var item in _lookup.SalesTeams) + { + @item.Name + } + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Start Date + + + + + Finish Date + + + + + Target Revenue + + + + + Description + + + + + + +
+ <_BudgetDataTable CampaignId="@_model.Id" Items="_budgets" Lookup="_lookup" ReadOnly="ReadOnly" OnChanged="RefreshDetails" /> +
+
+ +
+ <_ExpenseDataTable CampaignId="@_model.Id" Items="_expenses" Lookup="_lookup" ReadOnly="ReadOnly" OnChanged="RefreshDetails" /> +
+
+
+
+ + 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 UpdateCampaignRequest 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 UpdateCampaignRequest _model = new(); + private CampaignLookupResponse _lookup = new(); + private List _budgets = new(); + private List _expenses = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var resLookup = await CampaignService.GetLookupDataAsync(); + if (resLookup != null && resLookup.IsSuccess) + { + _lookup = resLookup.Value!; + } + + var resDetail = await CampaignService.GetCampaignByIdAsync(Data.Id!); + if (resDetail != null && resDetail.IsSuccess) + { + var d = resDetail.Value!; + _model = new UpdateCampaignRequest + { + Id = d.Id, + Title = d.Title, + Description = d.Description, + TargetRevenueAmount = d.TargetRevenueAmount, + CampaignDateStart = d.CampaignDateStart, + CampaignDateFinish = d.CampaignDateFinish, + Status = d.Status, + SalesTeamId = d.SalesTeamId, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + _budgets = d.Budgets; + _expenses = d.Expenses; + } + } + finally + { + _isDataLoading = false; + } + } + + private async Task RefreshDetails() + { + var res = await CampaignService.GetCampaignByIdAsync(_model.Id!); + if (res != null && res.IsSuccess) + { + _budgets = res.Value!.Budgets; + _expenses = res.Value!.Expenses; + + _model.CreatedAt = res.Value.CreatedAt; + _model.CreatedBy = res.Value.CreatedBy; + _model.UpdatedAt = res.Value.UpdatedAt; + _model.UpdatedBy = res.Value.UpdatedBy; + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CampaignService.UpdateCampaignAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Campaign 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/Pipeline/Campaign/Components/_ExpenseCreateForm.razor b/Features/Pipeline/Campaign/Components/_ExpenseCreateForm.razor new file mode 100644 index 0000000..489d529 --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_ExpenseCreateForm.razor @@ -0,0 +1,91 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using MudBlazor +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + + + + + + Expense Title + + + + Amount + + + + Date + + + + Status + + @foreach (var item in Lookup.ExpenseStatuses) + { + @item.Name + } + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Expense + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string CampaignId { get; set; } = string.Empty; + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + private MudForm _form = default!; + private CreateExpenseRequest _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.CampaignId = CampaignId; + try + { + var response = await CampaignService.AddExpenseAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Expense added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_ExpenseDataTable.razor b/Features/Pipeline/Campaign/Components/_ExpenseDataTable.razor new file mode 100644 index 0000000..27740ba --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_ExpenseDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using Indotalent.Features.Root.Shared +@using MudBlazor +@inject CampaignService CampaignService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Campaign Expenses + @if (!ReadOnly) + { + Add Expense + } +
+ + + No + Title + Amount + @if (!ReadOnly) + { + Actions + } + + + @expenseContext.AutoNumber + @expenseContext.Title + @expenseContext.Amount?.ToString("N0") + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string CampaignId { get; set; } = string.Empty; + [Parameter] public List Items { get; set; } = new(); + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var p = new DialogParameters { ["CampaignId"] = CampaignId, ["Lookup"] = Lookup }; + var d = await DialogService.ShowAsync<_ExpenseCreateForm>("Add Expense", p, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await d.Result; + if (result != null && !result.Canceled) + { + await OnChanged.InvokeAsync(); + } + } + + private async Task OnEditClick(CampaignExpenseResponse item) + { + var p = new DialogParameters { ["Data"] = item, ["Lookup"] = Lookup }; + var d = await DialogService.ShowAsync<_ExpenseUpdateForm>("Edit Expense", p, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await d.Result; + if (result != null && !result.Canceled) + { + await OnChanged.InvokeAsync(); + } + } + + private async Task OnDeleteClick(CampaignExpenseResponse item) + { + var p = new DialogParameters { ["ContentText"] = $"Are you sure you want to delete expense {item.AutoNumber}?" }; + var d = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", p, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await d.Result; + + if (result != null && !result.Canceled) + { + if (await CampaignService.DeleteExpenseAsync(item.Id!)) + { + Snackbar.Add("Expense removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Components/_ExpenseUpdateForm.razor b/Features/Pipeline/Campaign/Components/_ExpenseUpdateForm.razor new file mode 100644 index 0000000..574e06e --- /dev/null +++ b/Features/Pipeline/Campaign/Components/_ExpenseUpdateForm.razor @@ -0,0 +1,100 @@ +@using Indotalent.Features.Pipeline.Campaign.Cqrs +@using MudBlazor +@inject CampaignService CampaignService +@inject ISnackbar Snackbar + + + + + + + Expense Title + + + + Amount + + + + Date + + + + Status + + @foreach (var item in Lookup.ExpenseStatuses) + { + @item.Name + } + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public CampaignExpenseResponse Data { get; set; } = new(); + [Parameter] public CampaignLookupResponse Lookup { get; set; } = new(); + private MudForm _form = default!; + private UpdateExpenseRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Title = Data.Title; + _model.Amount = Data.Amount; + _model.ExpenseDate = Data.ExpenseDate; + _model.Status = Data.Status; + _model.Description = Data.Description; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await CampaignService.UpdateExpenseAsync(_model); + await Task.Delay(500); + + if (res != null && res.IsSuccess) + { + Snackbar.Add("Expense updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/CreateBudgetHandler.cs b/Features/Pipeline/Campaign/Cqrs/CreateBudgetHandler.cs new file mode 100644 index 0000000..8568b83 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/CreateBudgetHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CreateBudgetRequest +{ + public string? CampaignId { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } + public decimal? Amount { get; set; } + public BudgetStatus Status { get; set; } +} + +public class CreateBudgetResponse +{ + public string? Id { get; set; } + public string? Title { get; set; } +} + +public record CreateBudgetCommand(CreateBudgetRequest Data) : IRequest; + +public class CreateBudgetHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateBudgetHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBudgetCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.Budget + { + CampaignId = request.Data.CampaignId, + Title = request.Data.Title, + Description = request.Data.Description, + BudgetDate = request.Data.BudgetDate, + Amount = request.Data.Amount, + Status = request.Data.Status + }; + + _context.Budget.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBudgetResponse + { + Id = entity.Id, + Title = entity.Title + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/CreateCampaignHandler.cs b/Features/Pipeline/Campaign/Cqrs/CreateCampaignHandler.cs new file mode 100644 index 0000000..a2ba476 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/CreateCampaignHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CreateCampaignRequest +{ + public string? Title { get; set; } + public string? Description { get; set; } + public decimal? TargetRevenueAmount { get; set; } + public DateTime? CampaignDateStart { get; set; } + public DateTime? CampaignDateFinish { get; set; } + public CampaignStatus Status { get; set; } + public string? SalesTeamId { get; set; } +} + +public class CreateCampaignResponse +{ + public string? Id { get; set; } + public string? Title { get; set; } +} + +public record CreateCampaignCommand(CreateCampaignRequest Data) : IRequest; + +public class CreateCampaignHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateCampaignHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCampaignCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.Campaign + { + Title = request.Data.Title, + Description = request.Data.Description, + TargetRevenueAmount = request.Data.TargetRevenueAmount, + CampaignDateStart = request.Data.CampaignDateStart, + CampaignDateFinish = request.Data.CampaignDateFinish, + Status = request.Data.Status, + SalesTeamId = request.Data.SalesTeamId + }; + + _context.Campaign.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCampaignResponse + { + Id = entity.Id, + Title = entity.Title + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/CreateCampaignValidator.cs b/Features/Pipeline/Campaign/Cqrs/CreateCampaignValidator.cs new file mode 100644 index 0000000..760fa5a --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/CreateCampaignValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CreateCampaignValidator : AbstractValidator +{ + public CreateCampaignValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.SalesTeamId).NotEmpty().WithMessage("Sales Team is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/CreateExpenseHandler.cs b/Features/Pipeline/Campaign/Cqrs/CreateExpenseHandler.cs new file mode 100644 index 0000000..2dcfe18 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/CreateExpenseHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CreateExpenseRequest +{ + public string? CampaignId { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } + public decimal? Amount { get; set; } + public ExpenseStatus Status { get; set; } +} + +public class CreateExpenseResponse +{ + public string? Id { get; set; } + public string? Title { get; set; } +} + +public record CreateExpenseCommand(CreateExpenseRequest Data) : IRequest; + +public class CreateExpenseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateExpenseHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateExpenseCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.Expense + { + CampaignId = request.Data.CampaignId, + Title = request.Data.Title, + Description = request.Data.Description, + ExpenseDate = request.Data.ExpenseDate, + Amount = request.Data.Amount, + Status = request.Data.Status + }; + + _context.Expense.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateExpenseResponse + { + Id = entity.Id, + Title = entity.Title + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/DeleteBudgetHandler.cs b/Features/Pipeline/Campaign/Cqrs/DeleteBudgetHandler.cs new file mode 100644 index 0000000..72c925d --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/DeleteBudgetHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public record DeleteBudgetCommand(string Id) : IRequest; + +public class DeleteBudgetHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteBudgetHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBudgetCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Budget + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.Budget.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/DeleteCampaignByIdHandler.cs b/Features/Pipeline/Campaign/Cqrs/DeleteCampaignByIdHandler.cs new file mode 100644 index 0000000..22d2149 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/DeleteCampaignByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public record DeleteCampaignByIdCommand(string Id) : IRequest; + +public class DeleteCampaignByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteCampaignByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCampaignByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Campaign + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.Campaign.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/DeleteExpenseHandler.cs b/Features/Pipeline/Campaign/Cqrs/DeleteExpenseHandler.cs new file mode 100644 index 0000000..f821dd8 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/DeleteExpenseHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public record DeleteExpenseCommand(string Id) : IRequest; + +public class DeleteExpenseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteExpenseHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteExpenseCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Expense + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.Expense.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/GetCampaignByIdHandler.cs b/Features/Pipeline/Campaign/Cqrs/GetCampaignByIdHandler.cs new file mode 100644 index 0000000..c808a0c --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/GetCampaignByIdHandler.cs @@ -0,0 +1,100 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CampaignBudgetResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public decimal? Amount { get; set; } + public DateTime? BudgetDate { get; set; } + public BudgetStatus Status { get; set; } + public string? Description { get; set; } +} + +public class CampaignExpenseResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public decimal? Amount { get; set; } + public DateTime? ExpenseDate { get; set; } + public ExpenseStatus Status { get; set; } + public string? Description { get; set; } +} + +public class GetCampaignByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public decimal? TargetRevenueAmount { get; set; } + public DateTime? CampaignDateStart { get; set; } + public DateTime? CampaignDateFinish { get; set; } + public CampaignStatus Status { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Budgets { get; set; } = new(); + public List Expenses { get; set; } = new(); +} + +public record GetCampaignByIdQuery(string Id) : IRequest; + +public class GetCampaignByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetCampaignByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCampaignByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Campaign + .AsNoTracking() + .Include(x => x.BudgetList) + .Include(x => x.ExpenseList) + .Where(x => x.Id == request.Id) + .Select(x => new GetCampaignByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + Description = x.Description, + TargetRevenueAmount = x.TargetRevenueAmount, + CampaignDateStart = x.CampaignDateStart, + CampaignDateFinish = x.CampaignDateFinish, + Status = x.Status, + SalesTeamId = x.SalesTeamId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Budgets = x.BudgetList.Select(b => new CampaignBudgetResponse + { + Id = b.Id, + AutoNumber = b.AutoNumber, + Title = b.Title, + Amount = b.Amount, + BudgetDate = b.BudgetDate, + Status = b.Status, + Description = b.Description + }).ToList(), + Expenses = x.ExpenseList.Select(e => new CampaignExpenseResponse + { + Id = e.Id, + AutoNumber = e.AutoNumber, + Title = e.Title, + Amount = e.Amount, + ExpenseDate = e.ExpenseDate, + Status = e.Status, + Description = e.Description + }).ToList() + }).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/GetCampaignListHandler.cs b/Features/Pipeline/Campaign/Cqrs/GetCampaignListHandler.cs new file mode 100644 index 0000000..040c097 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/GetCampaignListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class GetCampaignListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? SalesTeamName { get; set; } + public DateTime? CampaignDateStart { get; set; } + public string? Status { get; set; } + public decimal? TargetRevenueAmount { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCampaignListQuery() : IRequest>; + +public class GetCampaignListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetCampaignListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCampaignListQuery request, CancellationToken cancellationToken) + { + return await _context.Campaign + .AsNoTracking() + .Include(x => x.SalesTeam) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetCampaignListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + SalesTeamName = x.SalesTeam != null ? x.SalesTeam.Name : string.Empty, + CampaignDateStart = x.CampaignDateStart, + Status = x.Status.GetDescription(), + TargetRevenueAmount = x.TargetRevenueAmount, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/GetCampaignLookupHandler.cs b/Features/Pipeline/Campaign/Cqrs/GetCampaignLookupHandler.cs new file mode 100644 index 0000000..6941de8 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/GetCampaignLookupHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Enums; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class CampaignLookupResponse +{ + public List SalesTeams { get; set; } = new(); + public List Statuses { get; set; } = new(); + public List BudgetStatuses { get; set; } = new(); + public List ExpenseStatuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetCampaignLookupQuery() : IRequest; + +public class GetCampaignLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetCampaignLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCampaignLookupQuery request, CancellationToken cancellationToken) + { + var response = new CampaignLookupResponse(); + + response.SalesTeams = await _context.SalesTeam + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(CampaignStatus)) + .Cast() + .Select(e => new LookupItem { Value = (int)e, Name = e.GetDescription() }) + .ToList(); + + response.BudgetStatuses = Enum.GetValues(typeof(BudgetStatus)) + .Cast() + .Select(e => new LookupItem { Value = (int)e, Name = e.GetDescription() }) + .ToList(); + + response.ExpenseStatuses = Enum.GetValues(typeof(ExpenseStatus)) + .Cast() + .Select(e => new LookupItem { Value = (int)e, Name = e.GetDescription() }) + .ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/UpdateBudgetHandler.cs b/Features/Pipeline/Campaign/Cqrs/UpdateBudgetHandler.cs new file mode 100644 index 0000000..b0ad3ae --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/UpdateBudgetHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class UpdateBudgetRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? BudgetDate { get; set; } + public decimal? Amount { get; set; } + public BudgetStatus Status { get; set; } +} + +public class UpdateBudgetResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBudgetCommand(UpdateBudgetRequest Data) : IRequest; + +public class UpdateBudgetHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateBudgetHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBudgetCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Budget + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateBudgetResponse { Id = request.Data.Id, Success = false }; + } + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.BudgetDate = request.Data.BudgetDate; + entity.Amount = request.Data.Amount; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBudgetResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/UpdateCampaignHandler.cs b/Features/Pipeline/Campaign/Cqrs/UpdateCampaignHandler.cs new file mode 100644 index 0000000..c06fdf3 --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/UpdateCampaignHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class UpdateCampaignRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public decimal? TargetRevenueAmount { get; set; } + public DateTime? CampaignDateStart { get; set; } + public DateTime? CampaignDateFinish { get; set; } + public CampaignStatus Status { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCampaignResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCampaignCommand(UpdateCampaignRequest Data) : IRequest; + +public class UpdateCampaignHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateCampaignHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCampaignCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Campaign + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCampaignResponse { Id = request.Data.Id, Success = false }; + } + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.TargetRevenueAmount = request.Data.TargetRevenueAmount; + entity.CampaignDateStart = request.Data.CampaignDateStart; + entity.CampaignDateFinish = request.Data.CampaignDateFinish; + entity.Status = request.Data.Status; + entity.SalesTeamId = request.Data.SalesTeamId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCampaignResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/UpdateCampaignValidator.cs b/Features/Pipeline/Campaign/Cqrs/UpdateCampaignValidator.cs new file mode 100644 index 0000000..7c6dfff --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/UpdateCampaignValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class UpdateCampaignValidator : AbstractValidator +{ + public UpdateCampaignValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.SalesTeamId).NotEmpty().WithMessage("Sales Team is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Campaign/Cqrs/UpdateExpenseHandler.cs b/Features/Pipeline/Campaign/Cqrs/UpdateExpenseHandler.cs new file mode 100644 index 0000000..add07ba --- /dev/null +++ b/Features/Pipeline/Campaign/Cqrs/UpdateExpenseHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Campaign.Cqrs; + +public class UpdateExpenseRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } + public decimal? Amount { get; set; } + public ExpenseStatus Status { get; set; } +} + +public class UpdateExpenseResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateExpenseCommand(UpdateExpenseRequest Data) : IRequest; + +public class UpdateExpenseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateExpenseHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateExpenseCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Expense + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateExpenseResponse { Id = request.Data.Id, Success = false }; + } + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.ExpenseDate = request.Data.ExpenseDate; + entity.Amount = request.Data.Amount; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateExpenseResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Components/ExpensePage.razor b/Features/Pipeline/Expense/Components/ExpensePage.razor new file mode 100644 index 0000000..7d2237b --- /dev/null +++ b/Features/Pipeline/Expense/Components/ExpensePage.razor @@ -0,0 +1,29 @@ +@page "/pipeline/expense" +@using Indotalent.Features.Pipeline.Expense +@using Indotalent.Features.Pipeline.Expense.Cqrs +@using Indotalent.Features.Pipeline.Expense.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_ExpenseCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_ExpenseUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_ExpenseDataTable 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 UpdateExpenseRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateExpenseRequest 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/Pipeline/Expense/Components/_ExpenseCreateForm.razor b/Features/Pipeline/Expense/Components/_ExpenseCreateForm.razor new file mode 100644 index 0000000..6145174 --- /dev/null +++ b/Features/Pipeline/Expense/Components/_ExpenseCreateForm.razor @@ -0,0 +1,102 @@ +@using Indotalent.Features.Pipeline.Expense +@using Indotalent.Features.Pipeline.Expense.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ExpenseService ExpenseService +@inject ISnackbar Snackbar + + + +
+ Add New Expense + Record actual spending for your campaign. +
+
+ + + + + + Title + + + + + Campaign + + @foreach (var item in _lookup.Campaigns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Amount + + + + + Expense Date + + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Create Expense + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateExpenseValidator _validator = new(); + private CreateExpenseRequest _model = new(); + private ExpenseLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ExpenseService.GetExpenseLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + var res = await ExpenseService.CreateExpenseAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) { Snackbar.Add("Created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + _processing = false; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Components/_ExpenseDataTable.razor b/Features/Pipeline/Expense/Components/_ExpenseDataTable.razor new file mode 100644 index 0000000..4afcd3f --- /dev/null +++ b/Features/Pipeline/Expense/Components/_ExpenseDataTable.razor @@ -0,0 +1,236 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Expense +@using Indotalent.Features.Pipeline.Expense.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ExpenseService ExpenseService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Expense Management + Manage and monitor actual spending for campaigns. +
+
+ + / + Pipeline + / + Expense +
+
+ + +
+
+ + Search +
+
+ + @if (_isExporting) + { + + } + else + { + + Excel + } + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + + Refresh + } + + @if (_selectedExpense != null) + { + View + Edit + Remove + + } + else + { + Add New Expense + } +
+
+ + + + + Number + Expense Title + Campaign + Amount + Status + + + + + + @context.AutoNumber + @context.Title + @context.CampaignTitle + @context.Amount?.ToString("N0") + @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 _expenses = new(); + private GetExpenseListResponse? _selectedExpense; + 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; + _selectedExpense = null; + StateHasChanged(); + var response = await ExpenseService.GetExpenseListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _expenses = response.Value ?? new List(); + } + _isRefreshing = false; + StateHasChanged(); + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _expenses; + return _expenses.Where(x => + (x.Title?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CampaignTitle?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; _selectedExpense = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Expenses"); + var currentRow = 1; + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Title"; + worksheet.Cell(currentRow, 3).Value = "Campaign"; + worksheet.Cell(currentRow, 4).Value = "Amount"; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.Title; + worksheet.Cell(currentRow, 3).Value = item.CampaignTitle; + worksheet.Cell(currentRow, 4).Value = item.Amount; + } + worksheet.Columns().AdjustToContents(); + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Expense_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + _isExporting = false; + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedExpense = null; } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedExpense = null; } + + private async Task InvokeEdit() { if (_selectedExpense != null) { var req = await MapToRequest(_selectedExpense.Id!); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedExpense != null) { var req = await MapToRequest(_selectedExpense.Id!); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToRequest(string id) + { + var res = await ExpenseService.GetExpenseByIdAsync(id); + if (res != null && res.IsSuccess && res.Value != null) + { + var d = res.Value; + return new UpdateExpenseRequest { Id = d.Id, Title = d.Title, Amount = d.Amount, ExpenseDate = d.ExpenseDate, Status = d.Status, CampaignId = d.CampaignId, Description = d.Description, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedExpense == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedExpense.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 ExpenseService.DeleteExpenseByIdAsync(_selectedExpense.Id!)) { await LoadData(); Snackbar.Add("Deleted", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Components/_ExpenseUpdateForm.razor b/Features/Pipeline/Expense/Components/_ExpenseUpdateForm.razor new file mode 100644 index 0000000..0ab16b7 --- /dev/null +++ b/Features/Pipeline/Expense/Components/_ExpenseUpdateForm.razor @@ -0,0 +1,195 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Expense +@using Indotalent.Features.Pipeline.Expense.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject ExpenseService ExpenseService +@inject ISnackbar Snackbar + + + +
+ @(ReadOnly ? "Expense Details" : "Edit Expense") + @(ReadOnly ? "Viewing actual expenditure." : "Modify existing expense record.") +
+
+ + + + + + Title + + + + + Campaign + + @foreach (var item in _lookup.Campaigns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Amount + + + + + Expense Date + + + + + 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 UpdateExpenseRequest 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 UpdateExpenseValidator _validator = new(); + private UpdateExpenseRequest _model = new(); + private ExpenseLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await ExpenseService.GetExpenseLookupAsync(); + if (res != null && res.IsSuccess) + { + _lookup = res.Value ?? new(); + } + + _model = new UpdateExpenseRequest + { + Id = Data.Id, + Title = Data.Title, + Description = Data.Description, + Amount = Data.Amount, + ExpenseDate = Data.ExpenseDate, + Status = Data.Status, + CampaignId = Data.CampaignId, + 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 res = await ExpenseService.UpdateExpenseAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) + { + Snackbar.Add("Expense 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/Pipeline/Expense/Cqrs/CreateExpenseHandler.cs b/Features/Pipeline/Expense/Cqrs/CreateExpenseHandler.cs new file mode 100644 index 0000000..0ff98f5 --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/CreateExpenseHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class CreateExpenseRequest +{ + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } = DateTime.Today; + public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft; + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } +} + +public class CreateExpenseResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateExpenseCommand(CreateExpenseRequest Data) : IRequest; + +public class CreateExpenseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateExpenseHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateExpenseCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Expense); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Expense + { + AutoNumber = autoNo, + Title = request.Data.Title, + Description = request.Data.Description, + ExpenseDate = request.Data.ExpenseDate, + Status = request.Data.Status, + Amount = request.Data.Amount, + CampaignId = request.Data.CampaignId + }; + + _context.Expense.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateExpenseResponse + { + Id = entity.Id, + Code = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/CreateExpenseValidator.cs b/Features/Pipeline/Expense/Cqrs/CreateExpenseValidator.cs new file mode 100644 index 0000000..8711c7c --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/CreateExpenseValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class CreateExpenseValidator : AbstractValidator +{ + public CreateExpenseValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Expense Title is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CampaignId) + .NotEmpty().WithMessage("Campaign is required"); + + RuleFor(x => x.Amount) + .NotNull().WithMessage("Amount is required"); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((CreateExpenseRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/DeleteExpenseByIdHandler.cs b/Features/Pipeline/Expense/Cqrs/DeleteExpenseByIdHandler.cs new file mode 100644 index 0000000..c98275f --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/DeleteExpenseByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public record DeleteExpenseByIdRequest(string Id); + +public record DeleteExpenseByIdCommand(DeleteExpenseByIdRequest Data) : IRequest; + +public class DeleteExpenseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteExpenseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteExpenseByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Expense + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Expense.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/GetExpenseByIdHandler.cs b/Features/Pipeline/Expense/Cqrs/GetExpenseByIdHandler.cs new file mode 100644 index 0000000..8a6d57a --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/GetExpenseByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class GetExpenseByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } + public Data.Enums.ExpenseStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetExpenseByIdQuery(string Id) : IRequest; + +public class GetExpenseByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetExpenseByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetExpenseByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Expense + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetExpenseByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + Description = x.Description, + ExpenseDate = x.ExpenseDate, + Status = x.Status, + Amount = x.Amount, + CampaignId = x.CampaignId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/GetExpenseListHandler.cs b/Features/Pipeline/Expense/Cqrs/GetExpenseListHandler.cs new file mode 100644 index 0000000..82f41d8 --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/GetExpenseListHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class GetExpenseListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? CampaignTitle { get; set; } + public decimal? Amount { get; set; } + public DateTime? ExpenseDate { get; set; } + public Data.Enums.ExpenseStatus Status { get; set; } +} + +public record GetExpenseListQuery() : IRequest>; + +public class GetExpenseListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetExpenseListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetExpenseListQuery request, CancellationToken cancellationToken) + { + return await _context.Expense + .AsNoTracking() + .Include(x => x.Campaign) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetExpenseListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + CampaignTitle = x.Campaign != null ? x.Campaign.Title : string.Empty, + Amount = x.Amount, + ExpenseDate = x.ExpenseDate, + Status = x.Status + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/GetExpenseLookupHandler.cs b/Features/Pipeline/Expense/Cqrs/GetExpenseLookupHandler.cs new file mode 100644 index 0000000..422a1cc --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/GetExpenseLookupHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class ExpenseLookupResponse +{ + public List Campaigns { get; set; } = new(); + public List Statuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetExpenseLookupQuery() : IRequest; + +public class GetExpenseLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetExpenseLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetExpenseLookupQuery request, CancellationToken cancellationToken) + { + var response = new ExpenseLookupResponse(); + + response.Campaigns = await _context.Campaign + .AsNoTracking() + .OrderBy(x => x.Title) + .Select(x => new LookupItem { Id = x.Id, Name = x.Title }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(ExpenseStatus)) + .Cast() + .Select(x => new LookupItem + { + Value = (int)x, + Name = x.ToString() + }) + .ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/UpdateExpenseHandler.cs b/Features/Pipeline/Expense/Cqrs/UpdateExpenseHandler.cs new file mode 100644 index 0000000..0bee38d --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/UpdateExpenseHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class UpdateExpenseRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? ExpenseDate { get; set; } + public ExpenseStatus Status { get; set; } + public decimal? Amount { get; set; } + public string? CampaignId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateExpenseResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateExpenseCommand(UpdateExpenseRequest Data) : IRequest; + +public class UpdateExpenseHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateExpenseHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateExpenseCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Expense + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateExpenseResponse { Id = request.Data.Id, Success = false }; + } + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.ExpenseDate = request.Data.ExpenseDate; + entity.Status = request.Data.Status; + entity.Amount = request.Data.Amount; + entity.CampaignId = request.Data.CampaignId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateExpenseResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/Cqrs/UpdateExpenseValidator.cs b/Features/Pipeline/Expense/Cqrs/UpdateExpenseValidator.cs new file mode 100644 index 0000000..298854f --- /dev/null +++ b/Features/Pipeline/Expense/Cqrs/UpdateExpenseValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Expense.Cqrs; + +public class UpdateExpenseValidator : AbstractValidator +{ + public UpdateExpenseValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Expense Title is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CampaignId) + .NotEmpty().WithMessage("Campaign is required"); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((UpdateExpenseRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/ExpenseEndpoint.cs b/Features/Pipeline/Expense/ExpenseEndpoint.cs new file mode 100644 index 0000000..d1e8d73 --- /dev/null +++ b/Features/Pipeline/Expense/ExpenseEndpoint.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.Expense.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.Expense; + +public static class ExpenseEndpoint +{ + public static void MapExpenseEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/expense").WithTags("Expenses") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetExpenseListQuery()); + return result.ToApiResponse("Expenses retrieved successfully"); + }) + .WithName("GetExpenseList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetExpenseByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Expense detail retrieved successfully" + : $"Expense with ID {id} not found"); + }) + .WithName("GetExpenseById"); + + group.MapPost("/", async (CreateExpenseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateExpenseCommand(request)); + return result.ToApiResponse("Expense has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateExpense"); + + group.MapPost("/update", async (UpdateExpenseRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateExpenseCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed."); + } + return result.ToApiResponse("Expense has been updated successfully"); + }) + .WithName("UpdateExpense"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteExpenseByIdCommand(new DeleteExpenseByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed."); + } + return true.ToApiResponse("Expense has been deleted successfully"); + }) + .WithName("DeleteExpenseById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetExpenseLookupQuery()); + return result.ToApiResponse("Expense lookup data retrieved successfully"); + }) + .WithName("GetExpenseLookup"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Expense/ExpenseService.cs b/Features/Pipeline/Expense/ExpenseService.cs new file mode 100644 index 0000000..f0f250c --- /dev/null +++ b/Features/Pipeline/Expense/ExpenseService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.Expense.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.Expense; + +public class ExpenseService : BaseService +{ + private readonly RestClient _client; + + public ExpenseService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetExpenseListAsync() + { + var request = new RestRequest("api/expense", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetExpenseByIdAsync(string id) + { + var request = new RestRequest($"api/expense/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateExpenseAsync(CreateExpenseRequest data) + { + var request = new RestRequest("api/expense", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteExpenseByIdAsync(string id) + { + var request = new RestRequest($"api/expense/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateExpenseAsync(UpdateExpenseRequest data) + { + var request = new RestRequest("api/expense/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetExpenseLookupAsync() + { + var request = new RestRequest("api/expense/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Components/LeadPage.razor b/Features/Pipeline/Lead/Components/LeadPage.razor new file mode 100644 index 0000000..55edc10 --- /dev/null +++ b/Features/Pipeline/Lead/Components/LeadPage.razor @@ -0,0 +1,29 @@ +@page "/pipeline/lead" +@using Indotalent.Features.Pipeline.Lead +@using Indotalent.Features.Pipeline.Lead.Cqrs +@using Indotalent.Features.Pipeline.Lead.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeadCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeadUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeadDataTable 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 UpdateLeadRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateLeadRequest 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/Pipeline/Lead/Components/_LeadCreateForm.razor b/Features/Pipeline/Lead/Components/_LeadCreateForm.razor new file mode 100644 index 0000000..0a9d066 --- /dev/null +++ b/Features/Pipeline/Lead/Components/_LeadCreateForm.razor @@ -0,0 +1,124 @@ +@using Indotalent.Features.Pipeline.Lead +@using Indotalent.Features.Pipeline.Lead.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadService LeadService +@inject ISnackbar Snackbar + + + +
+ Add New Lead + Enter full lead profile and company details. +
+
+ + + + + Basic Information + Lead Title + Description + + Campaign@foreach (var item in _lookup.Campaigns) { + @item.Name + } + + Sales Team@foreach (var item in _lookup.SalesTeams) { + @item.Name + } + + + Company Details + Company Name + Company Description + Street Address + City + State + Zip Code + Country + + Phone + Fax + Email + + Digital Presence + Website + WhatsApp + LinkedIn + Facebook + Instagram + Twitter + + Dates & Targeted Amount + Prospecting Date + Closing Estimation + Closing Actual + + Amount Targeted + Amount Closed + + BANT Scoring + Budget Score + Authority Score + Need Score + Timeline Score + + Pipeline & Closing + Pipeline Stage@foreach (var item in _lookup.PipelineStages) { + @item.Name + } + +Closing Status@foreach (var item in _lookup.ClosingStatuses) { + @item.Name +} + +Closing Note + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Create Lead + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateLeadValidator _validator = new(); + private CreateLeadRequest _model = new(); + private LeadLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await LeadService.GetLeadLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var res = await LeadService.CreateLeadAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) { Snackbar.Add("Created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Components/_LeadDataTable.razor b/Features/Pipeline/Lead/Components/_LeadDataTable.razor new file mode 100644 index 0000000..d02da7b --- /dev/null +++ b/Features/Pipeline/Lead/Components/_LeadDataTable.razor @@ -0,0 +1,248 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Lead +@using Indotalent.Features.Pipeline.Lead.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeadService LeadService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Lead Management + Manage business opportunities and sales pipeline. +
+
+ + / + Pipeline + / + Lead +
+
+ + +
+
+ + Search +
+
+ + @if (_isExporting) + { + + } + else + { + + Excel + } + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + + Refresh + } + + @if (_selectedLead != null) + { + View + Edit + Remove + + } + else + { + Add New Lead + } +
+
+ + + + + + Number + + + Title + + + Company + + + Stage + + + Closing Status + + + + + @context.AutoNumber + @context.Title + @context.CompanyName + @context.PipelineStage + @context.ClosingStatus + + + +
+
+ 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 _leads = new(); + private GetLeadListResponse? _selectedLead; + 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; + _selectedLead = null; + StateHasChanged(); + var response = await LeadService.GetLeadListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _leads = response.Value ?? new List(); + } + _isRefreshing = false; + StateHasChanged(); + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _leads; + return _leads.Where(x => + (x.Title?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CompanyName?.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; _selectedLead = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Leads"); + var currentRow = 1; + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Title"; + worksheet.Cell(currentRow, 3).Value = "Company"; + worksheet.Cell(currentRow, 4).Value = "Stage"; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.Title; + worksheet.Cell(currentRow, 3).Value = item.CompanyName; + worksheet.Cell(currentRow, 4).Value = item.PipelineStage.ToString(); + } + worksheet.Columns().AdjustToContents(); + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Lead_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + _isExporting = false; + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedLead = null; } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedLead = null; } + + private async Task InvokeEdit() { if (_selectedLead != null) { var req = await MapToRequest(_selectedLead.Id!); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedLead != null) { var req = await MapToRequest(_selectedLead.Id!); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToRequest(string id) + { + var res = await LeadService.GetLeadByIdAsync(id); + if (res != null && res.IsSuccess && res.Value != null) + { + var d = res.Value; + return new UpdateLeadRequest { Id = d.Id, Title = d.Title, Description = d.Description, CompanyName = d.CompanyName, CompanyDescription = d.CompanyDescription, CompanyAddressStreet = d.CompanyAddressStreet, CompanyAddressCity = d.CompanyAddressCity, CompanyAddressState = d.CompanyAddressState, CompanyAddressZipCode = d.CompanyAddressZipCode, CompanyAddressCountry = d.CompanyAddressCountry, CompanyPhoneNumber = d.CompanyPhoneNumber, CompanyFaxNumber = d.CompanyFaxNumber, CompanyEmail = d.CompanyEmail, CompanyWebsite = d.CompanyWebsite, CompanyWhatsApp = d.CompanyWhatsApp, CompanyLinkedIn = d.CompanyLinkedIn, CompanyFacebook = d.CompanyFacebook, CompanyInstagram = d.CompanyInstagram, CompanyTwitter = d.CompanyTwitter, DateProspecting = d.DateProspecting, DateClosingEstimation = d.DateClosingEstimation, DateClosingActual = d.DateClosingActual, AmountTargeted = d.AmountTargeted, AmountClosed = d.AmountClosed, BudgetScore = d.BudgetScore, AuthorityScore = d.AuthorityScore, NeedScore = d.NeedScore, TimelineScore = d.TimelineScore, PipelineStage = d.PipelineStage, ClosingStatus = d.ClosingStatus, ClosingNote = d.ClosingNote, CampaignId = d.CampaignId, SalesTeamId = d.SalesTeamId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedLead == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedLead.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 LeadService.DeleteLeadByIdAsync(_selectedLead.Id!)) { await LoadData(); Snackbar.Add("Deleted", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Components/_LeadUpdateForm.razor b/Features/Pipeline/Lead/Components/_LeadUpdateForm.razor new file mode 100644 index 0000000..13a15c8 --- /dev/null +++ b/Features/Pipeline/Lead/Components/_LeadUpdateForm.razor @@ -0,0 +1,177 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.Lead +@using Indotalent.Features.Pipeline.Lead.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadService LeadService +@inject ISnackbar Snackbar + + + +
+ @(ReadOnly ? "Lead Details" : "Edit Lead") + @(ReadOnly ? "Viewing sales lead information." : "Modify existing lead profile.") +
+
+ + + + + Basic Information + Lead Title + Description + + Campaign@foreach (var item in _lookup.Campaigns) { + @item.Name + } + + Sales Team@foreach (var item in _lookup.SalesTeams) { + @item.Name + } + + + Company Information + Company Name + Company Description + Street Address + City + State + Zip Code + Country + + Phone + Fax + Email + + Digital Presence + Website + WhatsApp + LinkedIn + Facebook + Instagram + Twitter + + Dates & Targeted Amount + Prospecting Date + Closing Estimation + Closing Actual + + Amount Targeted + Amount Closed + + BANT Scoring + Budget Score + Authority Score + Need Score + Timeline Score + + Pipeline & Closing + Pipeline Stage@foreach (var item in _lookup.PipelineStages) { + @item.Name + } + +Closing Status@foreach (var item in _lookup.ClosingStatuses) { + @item.Name +} + +Closing Note + +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 UpdateLeadRequest 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 UpdateLeadValidator _validator = new(); + private UpdateLeadRequest _model = new(); + private LeadLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await LeadService.GetLeadLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model = new UpdateLeadRequest + { + Id = Data.Id, + Title = Data.Title, + Description = Data.Description, + CompanyName = Data.CompanyName, + CompanyDescription = Data.CompanyDescription, + CompanyAddressStreet = Data.CompanyAddressStreet, + CompanyAddressCity = Data.CompanyAddressCity, + CompanyAddressState = Data.CompanyAddressState, + CompanyAddressZipCode = Data.CompanyAddressZipCode, + CompanyAddressCountry = Data.CompanyAddressCountry, + CompanyPhoneNumber = Data.CompanyPhoneNumber, + CompanyFaxNumber = Data.CompanyFaxNumber, + CompanyEmail = Data.CompanyEmail, + CompanyWebsite = Data.CompanyWebsite, + CompanyWhatsApp = Data.CompanyWhatsApp, + CompanyLinkedIn = Data.CompanyLinkedIn, + CompanyFacebook = Data.CompanyFacebook, + CompanyInstagram = Data.CompanyInstagram, + CompanyTwitter = Data.CompanyTwitter, + DateProspecting = Data.DateProspecting, + DateClosingEstimation = Data.DateClosingEstimation, + DateClosingActual = Data.DateClosingActual, + AmountTargeted = Data.AmountTargeted, + AmountClosed = Data.AmountClosed, + BudgetScore = Data.BudgetScore, + AuthorityScore = Data.AuthorityScore, + NeedScore = Data.NeedScore, + TimelineScore = Data.TimelineScore, + PipelineStage = Data.PipelineStage, + ClosingStatus = Data.ClosingStatus, + ClosingNote = Data.ClosingNote, + CampaignId = Data.CampaignId, + SalesTeamId = Data.SalesTeamId, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var res = await LeadService.UpdateLeadAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/CreateLeadHandler.cs b/Features/Pipeline/Lead/Cqrs/CreateLeadHandler.cs new file mode 100644 index 0000000..12f4d90 --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/CreateLeadHandler.cs @@ -0,0 +1,116 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Data.Enums; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class CreateLeadRequest +{ + public string? Title { get; set; } + public string? Description { get; set; } + public string? CompanyName { get; set; } + public string? CompanyDescription { get; set; } + public string? CompanyAddressStreet { get; set; } + public string? CompanyAddressCity { get; set; } + public string? CompanyAddressState { get; set; } + public string? CompanyAddressZipCode { get; set; } + public string? CompanyAddressCountry { get; set; } + public string? CompanyPhoneNumber { get; set; } + public string? CompanyFaxNumber { get; set; } + public string? CompanyEmail { get; set; } + public string? CompanyWebsite { get; set; } + public string? CompanyWhatsApp { get; set; } + public string? CompanyLinkedIn { get; set; } + public string? CompanyFacebook { get; set; } + public string? CompanyInstagram { get; set; } + public string? CompanyTwitter { get; set; } + public DateTime? DateProspecting { get; set; } = DateTime.Today; + public DateTime? DateClosingEstimation { get; set; } + public DateTime? DateClosingActual { get; set; } + public decimal? AmountTargeted { get; set; } + public decimal? AmountClosed { get; set; } + public decimal? BudgetScore { get; set; } + public decimal? AuthorityScore { get; set; } + public decimal? NeedScore { get; set; } + public decimal? TimelineScore { get; set; } + public PipelineStage PipelineStage { get; set; } = PipelineStage.Prospecting; + public ClosingStatus ClosingStatus { get; set; } = ClosingStatus.ClosedWon; + public string? ClosingNote { get; set; } + public string? CampaignId { get; set; } + public string? SalesTeamId { get; set; } +} + +public class CreateLeadResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateLeadCommand(CreateLeadRequest Data) : IRequest; + +public class CreateLeadHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeadHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeadCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Lead); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Lead + { + AutoNumber = autoNo, + Title = request.Data.Title, + Description = request.Data.Description, + CompanyName = request.Data.CompanyName, + CompanyDescription = request.Data.CompanyDescription, + CompanyAddressStreet = request.Data.CompanyAddressStreet, + CompanyAddressCity = request.Data.CompanyAddressCity, + CompanyAddressState = request.Data.CompanyAddressState, + CompanyAddressZipCode = request.Data.CompanyAddressZipCode, + CompanyAddressCountry = request.Data.CompanyAddressCountry, + CompanyPhoneNumber = request.Data.CompanyPhoneNumber, + CompanyFaxNumber = request.Data.CompanyFaxNumber, + CompanyEmail = request.Data.CompanyEmail, + CompanyWebsite = request.Data.CompanyWebsite, + CompanyWhatsApp = request.Data.CompanyWhatsApp, + CompanyLinkedIn = request.Data.CompanyLinkedIn, + CompanyFacebook = request.Data.CompanyFacebook, + CompanyInstagram = request.Data.CompanyInstagram, + CompanyTwitter = request.Data.CompanyTwitter, + DateProspecting = request.Data.DateProspecting, + DateClosingEstimation = request.Data.DateClosingEstimation, + DateClosingActual = request.Data.DateClosingActual, + AmountTargeted = request.Data.AmountTargeted, + AmountClosed = request.Data.AmountClosed, + BudgetScore = request.Data.BudgetScore, + AuthorityScore = request.Data.AuthorityScore, + NeedScore = request.Data.NeedScore, + TimelineScore = request.Data.TimelineScore, + PipelineStage = request.Data.PipelineStage, + ClosingStatus = request.Data.ClosingStatus, + ClosingNote = request.Data.ClosingNote, + CampaignId = request.Data.CampaignId, + SalesTeamId = request.Data.SalesTeamId + }; + + _context.Lead.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeadResponse + { + Id = entity.Id, + Code = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/CreateLeadValidator.cs b/Features/Pipeline/Lead/Cqrs/CreateLeadValidator.cs new file mode 100644 index 0000000..2598e59 --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/CreateLeadValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class CreateLeadValidator : AbstractValidator +{ + public CreateLeadValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Lead Title is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CompanyName) + .NotEmpty().WithMessage("Company Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CompanyEmail) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.CompanyEmail)); + + RuleFor(x => x.CampaignId) + .NotEmpty().WithMessage("Campaign is required"); + + RuleFor(x => x.SalesTeamId) + .NotEmpty().WithMessage("Sales Team is required"); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((CreateLeadRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/DeleteLeadByIdHandler.cs b/Features/Pipeline/Lead/Cqrs/DeleteLeadByIdHandler.cs new file mode 100644 index 0000000..303edca --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/DeleteLeadByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public record DeleteLeadByIdRequest(string Id); + +public record DeleteLeadByIdCommand(DeleteLeadByIdRequest Data) : IRequest; + +public class DeleteLeadByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeadByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeadByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Lead.FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + if (entity == null) return false; + + _context.Lead.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/GetLeadByIdHandler.cs b/Features/Pipeline/Lead/Cqrs/GetLeadByIdHandler.cs new file mode 100644 index 0000000..0a9747c --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/GetLeadByIdHandler.cs @@ -0,0 +1,106 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class GetLeadByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? CompanyName { get; set; } + public string? CompanyDescription { get; set; } + public string? CompanyAddressStreet { get; set; } + public string? CompanyAddressCity { get; set; } + public string? CompanyAddressState { get; set; } + public string? CompanyAddressZipCode { get; set; } + public string? CompanyAddressCountry { get; set; } + public string? CompanyPhoneNumber { get; set; } + public string? CompanyFaxNumber { get; set; } + public string? CompanyEmail { get; set; } + public string? CompanyWebsite { get; set; } + public string? CompanyWhatsApp { get; set; } + public string? CompanyLinkedIn { get; set; } + public string? CompanyFacebook { get; set; } + public string? CompanyInstagram { get; set; } + public string? CompanyTwitter { get; set; } + public DateTime? DateProspecting { get; set; } + public DateTime? DateClosingEstimation { get; set; } + public DateTime? DateClosingActual { get; set; } + public decimal? AmountTargeted { get; set; } + public decimal? AmountClosed { get; set; } + public decimal? BudgetScore { get; set; } + public decimal? AuthorityScore { get; set; } + public decimal? NeedScore { get; set; } + public decimal? TimelineScore { get; set; } + public PipelineStage PipelineStage { get; set; } + public ClosingStatus ClosingStatus { get; set; } + public string? ClosingNote { get; set; } + public string? CampaignId { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeadByIdQuery(string Id) : IRequest; + +public class GetLeadByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Lead + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetLeadByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + Description = x.Description, + CompanyName = x.CompanyName, + CompanyDescription = x.CompanyDescription, + CompanyAddressStreet = x.CompanyAddressStreet, + CompanyAddressCity = x.CompanyAddressCity, + CompanyAddressState = x.CompanyAddressState, + CompanyAddressZipCode = x.CompanyAddressZipCode, + CompanyAddressCountry = x.CompanyAddressCountry, + CompanyPhoneNumber = x.CompanyPhoneNumber, + CompanyFaxNumber = x.CompanyFaxNumber, + CompanyEmail = x.CompanyEmail, + CompanyWebsite = x.CompanyWebsite, + CompanyWhatsApp = x.CompanyWhatsApp, + CompanyLinkedIn = x.CompanyLinkedIn, + CompanyFacebook = x.CompanyFacebook, + CompanyInstagram = x.CompanyInstagram, + CompanyTwitter = x.CompanyTwitter, + DateProspecting = x.DateProspecting, + DateClosingEstimation = x.DateClosingEstimation, + DateClosingActual = x.DateClosingActual, + AmountTargeted = x.AmountTargeted, + AmountClosed = x.AmountClosed, + BudgetScore = x.BudgetScore, + AuthorityScore = x.AuthorityScore, + NeedScore = x.NeedScore, + TimelineScore = x.TimelineScore, + PipelineStage = x.PipelineStage, + ClosingStatus = x.ClosingStatus, + ClosingNote = x.ClosingNote, + CampaignId = x.CampaignId, + SalesTeamId = x.SalesTeamId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/GetLeadListHandler.cs b/Features/Pipeline/Lead/Cqrs/GetLeadListHandler.cs new file mode 100644 index 0000000..5b33db7 --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/GetLeadListHandler.cs @@ -0,0 +1,48 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class GetLeadListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Title { get; set; } + public string? CompanyName { get; set; } + public string? CampaignTitle { get; set; } + public string? SalesTeamName { get; set; } + public PipelineStage PipelineStage { get; set; } + public ClosingStatus ClosingStatus { get; set; } +} + +public record GetLeadListQuery() : IRequest>; + +public class GetLeadListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeadListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeadListQuery request, CancellationToken cancellationToken) + { + return await _context.Lead + .AsNoTracking() + .Include(x => x.Campaign) + .Include(x => x.SalesTeam) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetLeadListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Title = x.Title, + CompanyName = x.CompanyName, + CampaignTitle = x.Campaign != null ? x.Campaign.Title : string.Empty, + SalesTeamName = x.SalesTeam != null ? x.SalesTeam.Name : string.Empty, + PipelineStage = x.PipelineStage, + ClosingStatus = x.ClosingStatus + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/GetLeadLookupHandler.cs b/Features/Pipeline/Lead/Cqrs/GetLeadLookupHandler.cs new file mode 100644 index 0000000..70899f2 --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/GetLeadLookupHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class LeadLookupResponse +{ + public List Campaigns { get; set; } = new(); + public List SalesTeams { get; set; } = new(); + public List PipelineStages { get; set; } = new(); + public List ClosingStatuses { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetLeadLookupQuery() : IRequest; + +public class GetLeadLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadLookupQuery request, CancellationToken cancellationToken) + { + var response = new LeadLookupResponse(); + + response.Campaigns = await _context.Campaign.AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Title }).ToListAsync(cancellationToken); + + response.SalesTeams = await _context.SalesTeam.AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }).ToListAsync(cancellationToken); + + response.PipelineStages = Enum.GetValues(typeof(PipelineStage)).Cast() + .Select(x => new LookupItem { Value = (int)x, Name = x.ToString() }).ToList(); + + response.ClosingStatuses = Enum.GetValues(typeof(ClosingStatus)).Cast() + .Select(x => new LookupItem { Value = (int)x, Name = x.ToString() }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/UpdateLeadHandler.cs b/Features/Pipeline/Lead/Cqrs/UpdateLeadHandler.cs new file mode 100644 index 0000000..f6eea2c --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/UpdateLeadHandler.cs @@ -0,0 +1,107 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class UpdateLeadRequest +{ + public string? Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? CompanyName { get; set; } + public string? CompanyDescription { get; set; } + public string? CompanyAddressStreet { get; set; } + public string? CompanyAddressCity { get; set; } + public string? CompanyAddressState { get; set; } + public string? CompanyAddressZipCode { get; set; } + public string? CompanyAddressCountry { get; set; } + public string? CompanyPhoneNumber { get; set; } + public string? CompanyFaxNumber { get; set; } + public string? CompanyEmail { get; set; } + public string? CompanyWebsite { get; set; } + public string? CompanyWhatsApp { get; set; } + public string? CompanyLinkedIn { get; set; } + public string? CompanyFacebook { get; set; } + public string? CompanyInstagram { get; set; } + public string? CompanyTwitter { get; set; } + public DateTime? DateProspecting { get; set; } + public DateTime? DateClosingEstimation { get; set; } + public DateTime? DateClosingActual { get; set; } + public decimal? AmountTargeted { get; set; } + public decimal? AmountClosed { get; set; } + public decimal? BudgetScore { get; set; } + public decimal? AuthorityScore { get; set; } + public decimal? NeedScore { get; set; } + public decimal? TimelineScore { get; set; } + public PipelineStage PipelineStage { get; set; } + public ClosingStatus ClosingStatus { get; set; } + public string? ClosingNote { get; set; } + public string? CampaignId { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateLeadResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeadCommand(UpdateLeadRequest Data) : IRequest; + +public class UpdateLeadHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeadHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeadCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Lead + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateLeadResponse { Id = request.Data.Id, Success = false }; + + entity.Title = request.Data.Title; + entity.Description = request.Data.Description; + entity.CompanyName = request.Data.CompanyName; + entity.CompanyDescription = request.Data.CompanyDescription; + entity.CompanyAddressStreet = request.Data.CompanyAddressStreet; + entity.CompanyAddressCity = request.Data.CompanyAddressCity; + entity.CompanyAddressState = request.Data.CompanyAddressState; + entity.CompanyAddressZipCode = request.Data.CompanyAddressZipCode; + entity.CompanyAddressCountry = request.Data.CompanyAddressCountry; + entity.CompanyPhoneNumber = request.Data.CompanyPhoneNumber; + entity.CompanyFaxNumber = request.Data.CompanyFaxNumber; + entity.CompanyEmail = request.Data.CompanyEmail; + entity.CompanyWebsite = request.Data.CompanyWebsite; + entity.CompanyWhatsApp = request.Data.CompanyWhatsApp; + entity.CompanyLinkedIn = request.Data.CompanyLinkedIn; + entity.CompanyFacebook = request.Data.CompanyFacebook; + entity.CompanyInstagram = request.Data.CompanyInstagram; + entity.CompanyTwitter = request.Data.CompanyTwitter; + entity.DateProspecting = request.Data.DateProspecting; + entity.DateClosingEstimation = request.Data.DateClosingEstimation; + entity.DateClosingActual = request.Data.DateClosingActual; + entity.AmountTargeted = request.Data.AmountTargeted; + entity.AmountClosed = request.Data.AmountClosed; + entity.BudgetScore = request.Data.BudgetScore; + entity.AuthorityScore = request.Data.AuthorityScore; + entity.NeedScore = request.Data.NeedScore; + entity.TimelineScore = request.Data.TimelineScore; + entity.PipelineStage = request.Data.PipelineStage; + entity.ClosingStatus = request.Data.ClosingStatus; + entity.ClosingNote = request.Data.ClosingNote; + entity.CampaignId = request.Data.CampaignId; + entity.SalesTeamId = request.Data.SalesTeamId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeadResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/Cqrs/UpdateLeadValidator.cs b/Features/Pipeline/Lead/Cqrs/UpdateLeadValidator.cs new file mode 100644 index 0000000..73ff5b3 --- /dev/null +++ b/Features/Pipeline/Lead/Cqrs/UpdateLeadValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.Lead.Cqrs; + +public class UpdateLeadValidator : AbstractValidator +{ + public UpdateLeadValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.CompanyName).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.SalesTeamId).NotEmpty(); + } + + public Func>> ValidateValue() => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((UpdateLeadRequest)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/LeadEndpoint.cs b/Features/Pipeline/Lead/LeadEndpoint.cs new file mode 100644 index 0000000..e2f1442 --- /dev/null +++ b/Features/Pipeline/Lead/LeadEndpoint.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.Lead.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.Lead; + +public static class LeadEndpoint +{ + public static void MapLeadEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/lead").WithTags("Leads") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadListQuery()); + return result.ToApiResponse("Leads retrieved successfully"); + }) + .WithName("GetLeadList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeadByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Lead detail retrieved successfully" + : $"Lead with ID {id} not found"); + }) + .WithName("GetLeadById"); + + group.MapPost("/", async (CreateLeadRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeadCommand(request)); + return result.ToApiResponse("Lead has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLead"); + + group.MapPost("/update", async (UpdateLeadRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeadCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Lead not found."); + } + return result.ToApiResponse("Lead has been updated successfully"); + }) + .WithName("UpdateLead"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeadByIdCommand(new DeleteLeadByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Lead not found."); + } + return true.ToApiResponse("Lead has been deleted successfully"); + }) + .WithName("DeleteLeadById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadLookupQuery()); + return result.ToApiResponse("Lead lookup data retrieved successfully"); + }) + .WithName("GetLeadLookup"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/Lead/LeadService.cs b/Features/Pipeline/Lead/LeadService.cs new file mode 100644 index 0000000..341cf8c --- /dev/null +++ b/Features/Pipeline/Lead/LeadService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.Lead.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.Lead; + +public class LeadService : BaseService +{ + private readonly RestClient _client; + + public LeadService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetLeadListAsync() + { + var request = new RestRequest("api/lead", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeadByIdAsync(string id) + { + var request = new RestRequest($"api/lead/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeadAsync(CreateLeadRequest data) + { + var request = new RestRequest("api/lead", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteLeadByIdAsync(string id) + { + var request = new RestRequest($"api/lead/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateLeadAsync(UpdateLeadRequest data) + { + var request = new RestRequest("api/lead/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetLeadLookupAsync() + { + var request = new RestRequest("api/lead/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Components/LeadActivityPage.razor b/Features/Pipeline/LeadActivity/Components/LeadActivityPage.razor new file mode 100644 index 0000000..cc30aa5 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Components/LeadActivityPage.razor @@ -0,0 +1,27 @@ +@page "/pipeline/lead-activity" +@using Indotalent.Features.Pipeline.LeadActivity.Cqrs +@using Indotalent.Features.Pipeline.LeadActivity.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeadActivityCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeadActivityUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeadActivityDataTable 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 UpdateLeadActivityRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateLeadActivityRequest 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/Pipeline/LeadActivity/Components/_LeadActivityCreateForm.razor b/Features/Pipeline/LeadActivity/Components/_LeadActivityCreateForm.razor new file mode 100644 index 0000000..76c6add --- /dev/null +++ b/Features/Pipeline/LeadActivity/Components/_LeadActivityCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Pipeline.LeadActivity.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadActivityService LeadActivityService +@inject ISnackbar Snackbar + + + +
+ Add Lead Activity + Log a new interaction with a lead. +
+
+ + + + + Basic Information + + + Summary + + + + + Lead + + @foreach (var item in _lookup.Leads) + { + @item.Name + } + + + + + Activity Type + + @foreach (var item in _lookup.ActivityTypes) + { + @item.Name + } + + + + + Description + + + + Schedule + + + From Date + + + + + To Date + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Activity + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateLeadActivityValidator _validator = new(); + private CreateLeadActivityRequest _model = new(); + private LeadActivityLookupResponse _lookup = new(); + private bool _processing = false; + protected override async Task OnInitializedAsync() { var res = await LeadActivityService.GetLeadActivityLookupAsync(); if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); } + private async Task Submit() { await _form.Validate(); if (!_form.IsValid) return; _processing = true; try { var res = await LeadActivityService.CreateLeadActivityAsync(_model); await Task.Delay(500); if (res != null && res.IsSuccess) { Snackbar.Add("Created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } } finally { _processing = false; } } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Components/_LeadActivityDataTable.razor b/Features/Pipeline/LeadActivity/Components/_LeadActivityDataTable.razor new file mode 100644 index 0000000..2229f49 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Components/_LeadActivityDataTable.razor @@ -0,0 +1,221 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.LeadActivity +@using Indotalent.Features.Pipeline.LeadActivity.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeadActivityService LeadActivityService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Lead Activity Management + Track and manage all interactions related to leads. +
+
+ + / + Pipeline + / + Lead Activity +
+
+ + +
+
+ + Search +
+
+ + @if (_isExporting) + { + + } + else + { + Excel + } + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + Add New Activity + } +
+
+ + + + + Summary + + + Number + + + Date + + Type + + + + +
+ @context.Summary.ToInitial() + @context.Summary +
+
+ @context.AutoNumber + @DateTimeExtensions.ToString(context.FromDate) + @context.Type.ToString() +
+
+
+
+ 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 GetLeadActivityListResponse? _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 res = await LeadActivityService.GetLeadActivityListAsync(); + await Task.Delay(500); + if (res != null && res.IsSuccess) _items = res.Value ?? new(); + } + finally { _isRefreshing = false; StateHasChanged(); } + } + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => (x.Summary?.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; _selectedItem = 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; _selectedItem = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedItem = null; StateHasChanged(); } + private async Task InvokeEdit() { if (_selectedItem == null) return; var req = await MapToUpdateRequest(_selectedItem.Id!); if (req != null) await OnEdit.InvokeAsync(req); } + private async Task InvokeView() { if (_selectedItem == null) return; var req = await MapToUpdateRequest(_selectedItem.Id!); if (req != null) await OnView.InvokeAsync(req); } + private async Task MapToUpdateRequest(string id) + { + var res = await LeadActivityService.GetLeadActivityByIdAsync(id); + if (res != null && res.IsSuccess && res.Value != null) + { + var d = res.Value; + return new UpdateLeadActivityRequest { Id = d.Id, LeadId = d.LeadId, Summary = d.Summary, Description = d.Description, FromDate = d.FromDate, ToDate = d.ToDate, Type = d.Type, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + private async Task ExportToExcel() + { + _isExporting = true; StateHasChanged(); + try + { + await Task.Delay(1000); + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("LeadActivities"); + worksheet.Cell(1, 1).Value = "Summary"; worksheet.Cell(1, 2).Value = "Number"; worksheet.Cell(1, 3).Value = "Date"; worksheet.Cell(1, 4).Value = "Type"; + 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; + var row = 2; foreach (var item in GetFilteredData()) { worksheet.Cell(row, 1).Value = item.Summary; worksheet.Cell(row, 2).Value = item.AutoNumber; worksheet.Cell(row, 3).Value = item.FromDate?.ToString("yyyy-MM-dd"); worksheet.Cell(row, 4).Value = item.Type.ToString(); row++; } + worksheet.Columns().AdjustToContents(); + using var stream = new MemoryStream(); workbook.SaveAs(stream); + await JSRuntime.InvokeVoidAsync("downloadFile", "LeadActivity_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(stream.ToArray())); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + catch (Exception ex) { Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); } + finally { _isExporting = false; StateHasChanged(); } + } + private async Task OnDelete() + { + if (_selectedItem == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.Summary } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.ExtraSmall }); + var result = await dialog.Result; + if (result != null && !result.Canceled) { var success = await LeadActivityService.DeleteLeadActivityByIdAsync(_selectedItem.Id!); if (success) { _selectedItem = null; await LoadData(); Snackbar.Add("Deleted successfully", Severity.Success); } } + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Components/_LeadActivityUpdateForm.razor b/Features/Pipeline/LeadActivity/Components/_LeadActivityUpdateForm.razor new file mode 100644 index 0000000..165343d --- /dev/null +++ b/Features/Pipeline/LeadActivity/Components/_LeadActivityUpdateForm.razor @@ -0,0 +1,120 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.LeadActivity.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadActivityService LeadActivityService +@inject ISnackbar Snackbar + + + +
+ @(ReadOnly ? "Activity Details" : "Edit Activity") + @(ReadOnly ? "Viewing activity log." : "Modify activity details.") +
+
+ + + + + Basic Information + + + Summary + + + + + Lead + + @foreach (var item in _lookup.Leads) + { + @item.Name + } + + + + + Activity Type + + @foreach (var item in _lookup.ActivityTypes) + { + @item.Name + } + + + + + Description + + + + Schedule + + + From Date + + + + + To Date + + + + 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 UpdateLeadActivityRequest 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 UpdateLeadActivityValidator _validator = new(); + private UpdateLeadActivityRequest _model = new(); + private LeadActivityLookupResponse _lookup = new(); + private bool _processing = false; + protected override async Task OnInitializedAsync() + { + var res = await LeadActivityService.GetLeadActivityLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model = new UpdateLeadActivityRequest + { + Id = Data.Id, + LeadId = Data.LeadId, + Summary = Data.Summary, + Description = Data.Description, + FromDate = Data.FromDate, + ToDate = Data.ToDate, + Type = Data.Type, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + StateHasChanged(); + } + private async Task Submit() { await _form.Validate(); if (!_form.IsValid) return; _processing = true; try { var res = await LeadActivityService.UpdateLeadActivityAsync(_model); await Task.Delay(500); if (res != null && res.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } } finally { _processing = false; } } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityHandler.cs new file mode 100644 index 0000000..923cb2b --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityHandler.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.Pipeline.LeadActivity.Cqrs; + +public class CreateLeadActivityRequest +{ + public string? LeadId { get; set; } + public string? Summary { get; set; } + public string? Description { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public Data.Enums.LeadActivityType Type { get; set; } +} + +public class CreateLeadActivityResponse +{ + public string? Id { get; set; } + public string? Summary { get; set; } +} + +public record CreateLeadActivityCommand(CreateLeadActivityRequest Data) : IRequest; + +public class CreateLeadActivityHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeadActivityHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeadActivityCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.LeadActivity); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.LeadActivity + { + AutoNumber = autoNo, + LeadId = request.Data.LeadId, + Summary = request.Data.Summary, + Description = request.Data.Description, + FromDate = request.Data.FromDate, + ToDate = request.Data.ToDate, + Type = request.Data.Type + }; + + _context.LeadActivity.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeadActivityResponse + { + Id = entity.Id, + Summary = entity.Summary + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityValidator.cs b/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityValidator.cs new file mode 100644 index 0000000..025ace1 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/CreateLeadActivityValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class CreateLeadActivityValidator : AbstractValidator +{ + public CreateLeadActivityValidator() + { + RuleFor(x => x.Summary) + .NotEmpty().WithMessage("Summary is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.LeadId) + .NotEmpty().WithMessage("Lead association is required"); + + RuleFor(x => x.FromDate) + .NotEmpty().WithMessage("From Date is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/DeleteLeadActivityByIdHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/DeleteLeadActivityByIdHandler.cs new file mode 100644 index 0000000..e5a34f8 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/DeleteLeadActivityByIdHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public record DeleteLeadActivityByIdRequest(string Id); +public record DeleteLeadActivityByIdCommand(DeleteLeadActivityByIdRequest Data) : IRequest; + +public class DeleteLeadActivityByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeadActivityByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeadActivityByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeadActivity + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.LeadActivity.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityByIdHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityByIdHandler.cs new file mode 100644 index 0000000..7ff27c3 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class GetLeadActivityByIdResponse +{ + public string? Id { get; set; } + public string? LeadId { get; set; } + public string? AutoNumber { get; set; } + public string? Summary { get; set; } + public string? Description { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public Data.Enums.LeadActivityType Type { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeadActivityByIdQuery(string Id) : IRequest; + +public class GetLeadActivityByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadActivityByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadActivityByIdQuery request, CancellationToken cancellationToken) + { + return await _context.LeadActivity + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetLeadActivityByIdResponse + { + Id = x.Id, + LeadId = x.LeadId, + AutoNumber = x.AutoNumber, + Summary = x.Summary, + Description = x.Description, + FromDate = x.FromDate, + ToDate = x.ToDate, + Type = x.Type, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityListHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityListHandler.cs new file mode 100644 index 0000000..cace67d --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityListHandler.cs @@ -0,0 +1,39 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class GetLeadActivityListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Summary { get; set; } + public DateTime? FromDate { get; set; } + public Data.Enums.LeadActivityType Type { get; set; } +} + +public record GetLeadActivityListQuery() : IRequest>; + +public class GetLeadActivityListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeadActivityListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeadActivityListQuery request, CancellationToken cancellationToken) + { + return await _context.LeadActivity + .AsNoTracking() + .OrderByDescending(x => x.FromDate) + .Select(x => new GetLeadActivityListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Summary = x.Summary, + FromDate = x.FromDate, + Type = x.Type + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityLookupHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityLookupHandler.cs new file mode 100644 index 0000000..d04e5e5 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/GetLeadActivityLookupHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class LeadActivityLookupResponse +{ + public List Leads { get; set; } = new(); + public List ActivityTypes { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Value { get; set; } +} + +public record GetLeadActivityLookupQuery() : IRequest; + +public class GetLeadActivityLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadActivityLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadActivityLookupQuery request, CancellationToken cancellationToken) + { + var response = new LeadActivityLookupResponse(); + + response.Leads = await _context.Lead.AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Title }).ToListAsync(cancellationToken); + + response.ActivityTypes = Enum.GetValues(typeof(LeadActivityType)).Cast() + .Select(x => new LookupItem { Value = (int)x, Name = x.ToString() }).ToList(); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityHandler.cs b/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityHandler.cs new file mode 100644 index 0000000..2054a34 --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class UpdateLeadActivityRequest +{ + public string? Id { get; set; } + public string? LeadId { get; set; } + public string? Summary { get; set; } + public string? Description { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public Data.Enums.LeadActivityType Type { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateLeadActivityResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeadActivityCommand(UpdateLeadActivityRequest Data) : IRequest; + +public class UpdateLeadActivityHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeadActivityHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeadActivityCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeadActivity + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateLeadActivityResponse { Id = request.Data.Id, Success = false }; + } + + entity.LeadId = request.Data.LeadId; + entity.Summary = request.Data.Summary; + entity.Description = request.Data.Description; + entity.FromDate = request.Data.FromDate; + entity.ToDate = request.Data.ToDate; + entity.Type = request.Data.Type; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeadActivityResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityValidator.cs b/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityValidator.cs new file mode 100644 index 0000000..939da7f --- /dev/null +++ b/Features/Pipeline/LeadActivity/Cqrs/UpdateLeadActivityValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.LeadActivity.Cqrs; + +public class UpdateLeadActivityValidator : AbstractValidator +{ + public UpdateLeadActivityValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.Summary) + .NotEmpty().WithMessage("Summary is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.LeadId) + .NotEmpty().WithMessage("Lead association is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/LeadActivityEndpoint.cs b/Features/Pipeline/LeadActivity/LeadActivityEndpoint.cs new file mode 100644 index 0000000..1b7a490 --- /dev/null +++ b/Features/Pipeline/LeadActivity/LeadActivityEndpoint.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.LeadActivity.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.LeadActivity; + +public static class LeadActivityEndpoint +{ + public static void MapLeadActivityEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/lead-activity").WithTags("LeadActivities") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadActivityLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetLeadActivityLookup"); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadActivityListQuery()); + return result.ToApiResponse("Lead activity list retrieved successfully"); + }) + .WithName("GetLeadActivityList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeadActivityByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Lead activity detail retrieved successfully" + : $"Lead activity with ID {id} not found"); + }) + .WithName("GetLeadActivityById"); + + group.MapPost("/", async (CreateLeadActivityRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeadActivityCommand(request)); + return result.ToApiResponse("Lead activity has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLeadActivity"); + + group.MapPost("/update", async (UpdateLeadActivityRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeadActivityCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The lead activity data could not be found."); + } + return result.ToApiResponse("Lead activity has been updated successfully"); + }) + .WithName("UpdateLeadActivity"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeadActivityByIdCommand(new DeleteLeadActivityByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The lead activity data could not be found."); + } + return true.ToApiResponse("Lead activity has been deleted successfully"); + }) + .WithName("DeleteLeadActivityById"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadActivity/LeadActivityService.cs b/Features/Pipeline/LeadActivity/LeadActivityService.cs new file mode 100644 index 0000000..5f4b833 --- /dev/null +++ b/Features/Pipeline/LeadActivity/LeadActivityService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.LeadActivity.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.LeadActivity; + +public class LeadActivityService : BaseService +{ + private readonly RestClient _client; + + public LeadActivityService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetLeadActivityLookupAsync() + { + var request = new RestRequest("api/lead-activity/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task>?> GetLeadActivityListAsync() + { + var request = new RestRequest("api/lead-activity", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeadActivityByIdAsync(string id) + { + var request = new RestRequest($"api/lead-activity/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeadActivityAsync(CreateLeadActivityRequest data) + { + var request = new RestRequest("api/lead-activity", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateLeadActivityAsync(UpdateLeadActivityRequest data) + { + var request = new RestRequest("api/lead-activity/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteLeadActivityByIdAsync(string id) + { + var request = new RestRequest($"api/lead-activity/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Components/LeadContactPage.razor b/Features/Pipeline/LeadContact/Components/LeadContactPage.razor new file mode 100644 index 0000000..f5c32b6 --- /dev/null +++ b/Features/Pipeline/LeadContact/Components/LeadContactPage.razor @@ -0,0 +1,28 @@ +@page "/pipeline/lead-contact" +@using Indotalent.Features.Pipeline.LeadContact.Cqrs +@using Indotalent.Features.Pipeline.LeadContact.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeadContactCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeadContactUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeadContactDataTable 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 UpdateLeadContactRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateLeadContactRequest 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/Pipeline/LeadContact/Components/_LeadContactCreateForm.razor b/Features/Pipeline/LeadContact/Components/_LeadContactCreateForm.razor new file mode 100644 index 0000000..ab5194e --- /dev/null +++ b/Features/Pipeline/LeadContact/Components/_LeadContactCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Pipeline.LeadContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadContactService LeadContactService +@inject ISnackbar Snackbar + + +
+ +
+ Add Lead Contact + Create a new contact entry for your pipeline. +
+
+
+ + + + + + Basic Information + + + Full Name + + + + + Lead + + @foreach (var item in _lookup.Leads) + { + @item.Name + } + + + + + Description + + + + Address Info + + Street Address + City + State + Zip Code + Country + + Contact Details + + Phone Number + Fax Number + Mobile Number + Email Address + Website + + Digital Presence + + WhatsApp + LinkedIn + Facebook + Twitter + Instagram + + +
+ 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 CreateLeadContactValidator _validator = new(); + private CreateLeadContactRequest _model = new(); + private bool _processing = false; + private LeadContactLookupResponse _lookup = new(); + + protected override async Task OnInitializedAsync() + { + var res = await LeadContactService.GetLeadContactLookupAsync(); + if (res != null && res.IsSuccess) + { + _lookup = res.Value ?? new(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var res = await LeadContactService.CreateLeadContactAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) + { + Snackbar.Add("Created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Components/_LeadContactDataTable.razor b/Features/Pipeline/LeadContact/Components/_LeadContactDataTable.razor new file mode 100644 index 0000000..ed4842e --- /dev/null +++ b/Features/Pipeline/LeadContact/Components/_LeadContactDataTable.razor @@ -0,0 +1,397 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.LeadContact +@using Indotalent.Features.Pipeline.LeadContact.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeadContactService LeadContactService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Lead Contact Management + Manage pipeline contacts and communication details. +
+
+ + / + Pipeline + / + Lead Contact +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New Contact + + } +
+
+ + + + + + Contact Name + + + Auto Number + + Mobile + + Email + + + + + + + +
+ + @context.FullName.ToInitial() + + @context.FullName +
+
+ @context.AutoNumber + @context.MobileNumber + @context.Email +
+
+ +
+
+ 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 GetLeadContactListResponse? _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 LeadContactService.GetLeadContactListAsync(); + 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.FullName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.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; + _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("LeadContacts"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Full Name"; + worksheet.Cell(currentRow, 2).Value = "Auto Number"; + worksheet.Cell(currentRow, 3).Value = "Mobile Number"; + worksheet.Cell(currentRow, 4).Value = "Email"; + + 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.AutoNumber; + worksheet.Cell(currentRow, 3).Value = item.MobileNumber; + worksheet.Cell(currentRow, 4).Value = item.Email; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "LeadContact_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 LeadContactService.GetLeadContactByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateLeadContactRequest + { + Id = d.Id, LeadId = d.LeadId, FullName = d.FullName, Description = d.Description, + AddressStreet = d.AddressStreet, AddressCity = d.AddressCity, AddressState = d.AddressState, + AddressZipCode = d.AddressZipCode, AddressCountry = d.AddressCountry, PhoneNumber = d.PhoneNumber, + FaxNumber = d.FaxNumber, MobileNumber = d.MobileNumber, Email = d.Email, Website = d.Website, + WhatsApp = d.WhatsApp, LinkedIn = d.LinkedIn, Facebook = d.Facebook, Twitter = d.Twitter, + Instagram = d.Instagram, 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.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 LeadContactService.DeleteLeadContactByIdAsync(_selectedItem.Id!); + if (isSuccess) + { + _selectedItem = null; + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Components/_LeadContactUpdateForm.razor b/Features/Pipeline/LeadContact/Components/_LeadContactUpdateForm.razor new file mode 100644 index 0000000..dbc0d4f --- /dev/null +++ b/Features/Pipeline/LeadContact/Components/_LeadContactUpdateForm.razor @@ -0,0 +1,170 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.LeadContact +@using Indotalent.Features.Pipeline.LeadContact.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeadContactService LeadContactService +@inject ISnackbar Snackbar + + + +
+ @(ReadOnly ? "Lead Contact Details" : "Edit Lead Contact") + @(ReadOnly ? "Viewing pipeline contact specification." : "Modify existing contact information.") +
+
+ + + + + + Basic Information + + + Full Name + + + + + Lead + + @foreach (var item in _lookup.Leads) + { + @item.Name + } + + + + + Description + + + + Address Information + + Street Address + City + State + Zip Code + Country + + Contact & Communication + + Phone Number + Fax Number + Mobile Number + Email Address + Website + + Digital Presence + + WhatsApp + LinkedIn + Facebook + Twitter + Instagram + + 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 UpdateLeadContactRequest 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 UpdateLeadContactValidator _validator = new(); + private UpdateLeadContactRequest _model = new(); + private bool _processing = false; + private LeadContactLookupResponse _lookup = new(); + + protected override async Task OnInitializedAsync() + { + var res = await LeadContactService.GetLeadContactLookupAsync(); + if (res != null && res.IsSuccess) + { + _lookup = res.Value ?? new(); + } + + _model = new UpdateLeadContactRequest + { + Id = Data.Id, + LeadId = Data.LeadId, + FullName = Data.FullName, + Description = Data.Description, + AddressStreet = Data.AddressStreet, + AddressCity = Data.AddressCity, + AddressState = Data.AddressState, + AddressZipCode = Data.AddressZipCode, + AddressCountry = Data.AddressCountry, + PhoneNumber = Data.PhoneNumber, + FaxNumber = Data.FaxNumber, + MobileNumber = Data.MobileNumber, + Email = Data.Email, + Website = Data.Website, + WhatsApp = Data.WhatsApp, + LinkedIn = Data.LinkedIn, + Facebook = Data.Facebook, + Twitter = Data.Twitter, + Instagram = Data.Instagram, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + StateHasChanged(); + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var res = await LeadContactService.UpdateLeadContactAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/ChangeLeadContactAvatarHandler.cs b/Features/Pipeline/LeadContact/Cqrs/ChangeLeadContactAvatarHandler.cs new file mode 100644 index 0000000..08ea29c --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/ChangeLeadContactAvatarHandler.cs @@ -0,0 +1,66 @@ +using Indotalent.Infrastructure.Database; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class ChangeLeadContactAvatarRequest +{ + public string Id { get; set; } = string.Empty; + public byte[] FileData { get; set; } = Array.Empty(); + public string FileName { get; set; } = string.Empty; +} + +public class ChangeLeadContactAvatarResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record ChangeLeadContactAvatarCommand(ChangeLeadContactAvatarRequest Data) : IRequest; + +public class ChangeLeadContactAvatarHandler : IRequestHandler +{ + private readonly AppDbContext _context; + private readonly FileStorageService _fileStorage; + + public ChangeLeadContactAvatarHandler(AppDbContext context, FileStorageService fileStorage) + { + _context = context; + _fileStorage = fileStorage; + } + + public async Task Handle(ChangeLeadContactAvatarCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeadContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new ChangeLeadContactAvatarResponse { IsSuccess = false, Message = "Lead Contact not found" }; + + try + { + var extension = Path.GetExtension(request.Data.FileName); + + if (!string.IsNullOrEmpty(entity.AvatarName)) + { + _fileStorage.DeleteOldAvatar(entity.AvatarName); + } + + var newFileName = await _fileStorage.SaveAvatarAsync(entity.Id, request.Data.FileData, extension); + + entity.AvatarName = newFileName; + await _context.SaveChangesAsync(cancellationToken); + + return new ChangeLeadContactAvatarResponse + { + IsSuccess = true, + Message = "Lead Contact avatar updated successfully" + }; + } + catch (Exception ex) + { + return new ChangeLeadContactAvatarResponse { IsSuccess = false, Message = ex.Message }; + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactHandler.cs b/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactHandler.cs new file mode 100644 index 0000000..8313bb5 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactHandler.cs @@ -0,0 +1,89 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class CreateLeadContactRequest +{ + public string? LeadId { get; set; } + public string? FullName { get; set; } + public string? Description { get; set; } + public string? AddressStreet { get; set; } + public string? AddressCity { get; set; } + public string? AddressState { get; set; } + public string? AddressZipCode { get; set; } + public string? AddressCountry { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? MobileNumber { get; set; } + public string? Email { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Twitter { get; set; } + public string? Instagram { get; set; } + public string? AvatarName { get; set; } +} + +public class CreateLeadContactResponse +{ + public string? Id { get; set; } + public string? FullName { get; set; } +} + +public record CreateLeadContactCommand(CreateLeadContactRequest Data) : IRequest; + +public class CreateLeadContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeadContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeadContactCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.LeadContact); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.LeadContact + { + AutoNumber = autoNo, + LeadId = request.Data.LeadId, + FullName = request.Data.FullName, + Description = request.Data.Description, + AddressStreet = request.Data.AddressStreet, + AddressCity = request.Data.AddressCity, + AddressState = request.Data.AddressState, + AddressZipCode = request.Data.AddressZipCode, + AddressCountry = request.Data.AddressCountry, + PhoneNumber = request.Data.PhoneNumber, + FaxNumber = request.Data.FaxNumber, + MobileNumber = request.Data.MobileNumber, + Email = request.Data.Email, + Website = request.Data.Website, + WhatsApp = request.Data.WhatsApp, + LinkedIn = request.Data.LinkedIn, + Facebook = request.Data.Facebook, + Twitter = request.Data.Twitter, + Instagram = request.Data.Instagram, + AvatarName = request.Data.AvatarName + }; + + _context.LeadContact.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeadContactResponse + { + Id = entity.Id, + FullName = entity.FullName + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactValidator.cs b/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactValidator.cs new file mode 100644 index 0000000..6075f42 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/CreateLeadContactValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class CreateLeadContactValidator : AbstractValidator +{ + public CreateLeadContactValidator() + { + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Email) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.Email)) + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.LeadId) + .NotEmpty().WithMessage("Lead association is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/DeleteLeadContactByIdHandler.cs b/Features/Pipeline/LeadContact/Cqrs/DeleteLeadContactByIdHandler.cs new file mode 100644 index 0000000..b85f6aa --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/DeleteLeadContactByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public record DeleteLeadContactByIdRequest(string Id); + +public record DeleteLeadContactByIdCommand(DeleteLeadContactByIdRequest Data) : IRequest; + +public class DeleteLeadContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeadContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeadContactByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeadContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.LeadContact.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/GetLeadContactAvatarInfoHandler.cs b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactAvatarInfoHandler.cs new file mode 100644 index 0000000..a441ef9 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactAvatarInfoHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class GetLeadContactAvatarInfoResponse +{ + public string Id { get; set; } = string.Empty; + public string? FullName { get; set; } + public string? AvatarName { get; set; } +} + +public record GetLeadContactAvatarInfoQuery(string Id) : IRequest; + +public class GetLeadContactAvatarInfoHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadContactAvatarInfoHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(GetLeadContactAvatarInfoQuery request, CancellationToken cancellationToken) + { + var entity = await _context.LeadContact + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return null; + + return new GetLeadContactAvatarInfoResponse + { + Id = entity.Id, + FullName = entity.FullName, + AvatarName = entity.AvatarName + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/GetLeadContactByIdHandler.cs b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactByIdHandler.cs new file mode 100644 index 0000000..8c42981 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactByIdHandler.cs @@ -0,0 +1,79 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class GetLeadContactByIdResponse +{ + public string? Id { get; set; } + public string? LeadId { get; set; } + public string? AutoNumber { get; set; } + public string? FullName { get; set; } + public string? Description { get; set; } + public string? AddressStreet { get; set; } + public string? AddressCity { get; set; } + public string? AddressState { get; set; } + public string? AddressZipCode { get; set; } + public string? AddressCountry { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? MobileNumber { get; set; } + public string? Email { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Twitter { get; set; } + public string? Instagram { get; set; } + public string? AvatarName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeadContactByIdQuery(string Id) : IRequest; + +public class GetLeadContactByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadContactByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadContactByIdQuery request, CancellationToken cancellationToken) + { + return await _context.LeadContact + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetLeadContactByIdResponse + { + Id = x.Id, + LeadId = x.LeadId, + AutoNumber = x.AutoNumber, + FullName = x.FullName, + Description = x.Description, + AddressStreet = x.AddressStreet, + AddressCity = x.AddressCity, + AddressState = x.AddressState, + AddressZipCode = x.AddressZipCode, + AddressCountry = x.AddressCountry, + PhoneNumber = x.PhoneNumber, + FaxNumber = x.FaxNumber, + MobileNumber = x.MobileNumber, + Email = x.Email, + Website = x.Website, + WhatsApp = x.WhatsApp, + LinkedIn = x.LinkedIn, + Facebook = x.Facebook, + Twitter = x.Twitter, + Instagram = x.Instagram, + AvatarName = x.AvatarName, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/GetLeadContactListHandler.cs b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactListHandler.cs new file mode 100644 index 0000000..a5bd846 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactListHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class GetLeadContactListResponse +{ + public string? Id { get; set; } + public string? LeadId { get; set; } + public string? AutoNumber { get; set; } + public string? FullName { get; set; } + public string? MobileNumber { get; set; } + public string? Email { get; set; } + public string? AvatarName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeadContactListQuery() : IRequest>; + +public class GetLeadContactListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeadContactListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeadContactListQuery request, CancellationToken cancellationToken) + { + return await _context.LeadContact + .AsNoTracking() + .OrderBy(x => x.FullName) + .Select(x => new GetLeadContactListResponse + { + Id = x.Id, + LeadId = x.LeadId, + AutoNumber = x.AutoNumber, + FullName = x.FullName, + MobileNumber = x.MobileNumber, + Email = x.Email, + AvatarName = x.AvatarName, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/GetLeadContactLookupHandler.cs b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactLookupHandler.cs new file mode 100644 index 0000000..af733e2 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/GetLeadContactLookupHandler.cs @@ -0,0 +1,39 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class LeadContactLookupResponse +{ + public List Leads { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record GetLeadContactLookupQuery() : IRequest; + +public class GetLeadContactLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeadContactLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeadContactLookupQuery request, CancellationToken cancellationToken) + { + var response = new LeadContactLookupResponse(); + + response.Leads = await _context.Lead.AsNoTracking() + .Select(x => new LookupItem + { + Id = x.Id, + Name = x.Title + }).ToListAsync(cancellationToken); + + return response; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactHandler.cs b/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactHandler.cs new file mode 100644 index 0000000..18120e6 --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactHandler.cs @@ -0,0 +1,88 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class UpdateLeadContactRequest +{ + public string? Id { get; set; } + public string? LeadId { get; set; } + public string? FullName { get; set; } + public string? Description { get; set; } + public string? AddressStreet { get; set; } + public string? AddressCity { get; set; } + public string? AddressState { get; set; } + public string? AddressZipCode { get; set; } + public string? AddressCountry { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? MobileNumber { get; set; } + public string? Email { get; set; } + public string? Website { get; set; } + public string? WhatsApp { get; set; } + public string? LinkedIn { get; set; } + public string? Facebook { get; set; } + public string? Twitter { get; set; } + public string? Instagram { get; set; } + public string? AvatarName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateLeadContactResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeadContactCommand(UpdateLeadContactRequest Data) : IRequest; + +public class UpdateLeadContactHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeadContactHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeadContactCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeadContact + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateLeadContactResponse { Id = request.Data.Id, Success = false }; + } + + entity.LeadId = request.Data.LeadId; + entity.FullName = request.Data.FullName; + entity.Description = request.Data.Description; + entity.AddressStreet = request.Data.AddressStreet; + entity.AddressCity = request.Data.AddressCity; + entity.AddressState = request.Data.AddressState; + entity.AddressZipCode = request.Data.AddressZipCode; + entity.AddressCountry = request.Data.AddressCountry; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.FaxNumber = request.Data.FaxNumber; + entity.MobileNumber = request.Data.MobileNumber; + entity.Email = request.Data.Email; + entity.Website = request.Data.Website; + entity.WhatsApp = request.Data.WhatsApp; + entity.LinkedIn = request.Data.LinkedIn; + entity.Facebook = request.Data.Facebook; + entity.Twitter = request.Data.Twitter; + entity.Instagram = request.Data.Instagram; + entity.AvatarName = request.Data.AvatarName; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeadContactResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactValidator.cs b/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactValidator.cs new file mode 100644 index 0000000..497a0ba --- /dev/null +++ b/Features/Pipeline/LeadContact/Cqrs/UpdateLeadContactValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.LeadContact.Cqrs; + +public class UpdateLeadContactValidator : AbstractValidator +{ + public UpdateLeadContactValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Email) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.Email)) + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.LeadId) + .NotEmpty().WithMessage("Lead association is required"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/LeadContactEndpoint.cs b/Features/Pipeline/LeadContact/LeadContactEndpoint.cs new file mode 100644 index 0000000..4b856e8 --- /dev/null +++ b/Features/Pipeline/LeadContact/LeadContactEndpoint.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.LeadContact.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.LeadContact; + +public static class LeadContactEndpoint +{ + public static void MapLeadContactEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/lead-contact").WithTags("LeadContacts") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadContactLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetLeadContactLookup"); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeadContactListQuery()); + return result.ToApiResponse("Lead contact list retrieved successfully"); + }) + .WithName("GetLeadContactList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeadContactByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Lead contact detail retrieved successfully" + : $"Lead contact with ID {id} not found"); + }) + .WithName("GetLeadContactById"); + + group.MapPost("/", async (CreateLeadContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeadContactCommand(request)); + return result.ToApiResponse("Lead contact has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLeadContact"); + + group.MapPost("/update", async (UpdateLeadContactRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeadContactCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The lead contact data could not be found."); + } + return result.ToApiResponse("Lead contact has been updated successfully"); + }) + .WithName("UpdateLeadContact"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeadContactByIdCommand(new DeleteLeadContactByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The lead contact data could not be found."); + } + return true.ToApiResponse("Lead contact has been deleted successfully"); + }) + .WithName("DeleteLeadContactById"); + + group.MapPost("/change-avatar", async (ChangeLeadContactAvatarRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeLeadContactAvatarCommand(request)); + return result.ToApiResponse(result.Message); + }) + .WithName("ChangeLeadContactAvatar"); + + group.MapGet("/avatar-info/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeadContactAvatarInfoQuery(id)); + return result.ToApiResponse(result is not null ? "Avatar info retrieved" : "Not found"); + }) + .WithName("GetLeadContactAvatarInfo"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/LeadContact/LeadContactService.cs b/Features/Pipeline/LeadContact/LeadContactService.cs new file mode 100644 index 0000000..9ecbda1 --- /dev/null +++ b/Features/Pipeline/LeadContact/LeadContactService.cs @@ -0,0 +1,78 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.LeadContact.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.LeadContact; + +public class LeadContactService : BaseService +{ + private readonly RestClient _client; + + public LeadContactService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetLeadContactLookupAsync() + { + var request = new RestRequest("api/lead-contact/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task>?> GetLeadContactListAsync() + { + var request = new RestRequest("api/lead-contact", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeadContactByIdAsync(string id) + { + var request = new RestRequest($"api/lead-contact/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeadContactAsync(CreateLeadContactRequest data) + { + var request = new RestRequest("api/lead-contact", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateLeadContactAsync(UpdateLeadContactRequest data) + { + var request = new RestRequest("api/lead-contact/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteLeadContactByIdAsync(string id) + { + var request = new RestRequest($"api/lead-contact/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> ChangeAvatarAsync(ChangeLeadContactAvatarRequest data) + { + var request = new RestRequest("api/lead-contact/change-avatar", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetAvatarInfoAsync(string id) + { + var request = new RestRequest($"api/lead-contact/avatar-info/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Pipeline/PipelinePage.razor b/Features/Pipeline/PipelinePage.razor new file mode 100644 index 0000000..35a905f --- /dev/null +++ b/Features/Pipeline/PipelinePage.razor @@ -0,0 +1,123 @@ +@page "/pipeline" +@using Indotalent.Features.Pipeline.Budget.Components +@using Indotalent.Features.Pipeline.Campaign.Components +@using Indotalent.Features.Pipeline.Expense.Components +@using Indotalent.Features.Pipeline.Lead.Components +@using Indotalent.Features.Pipeline.LeadActivity.Components +@using Indotalent.Features.Pipeline.LeadContact.Components +@using Indotalent.Features.Pipeline.SalesRepresentative.Components +@using Indotalent.Features.Pipeline.SalesTeam.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, "campaign" }, + { 1, "budget" }, + { 2, "expense" }, + { 3, "leads" }, + { 4, "lead-contacts" }, + { 5, "lead-activities" }, + { 6, "sales-team" }, + { 7, "sales-rep" } + }; + + 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($"/pipeline?tab={tabName ?? "campaign"}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Components/SalesRepresentativePage.razor b/Features/Pipeline/SalesRepresentative/Components/SalesRepresentativePage.razor new file mode 100644 index 0000000..200f7f8 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Components/SalesRepresentativePage.razor @@ -0,0 +1,28 @@ +@page "/pipeline/sales-representative" +@using Indotalent.Features.Pipeline.SalesRepresentative +@using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs +@using Indotalent.Features.Pipeline.SalesRepresentative.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SalesRepresentativeCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SalesRepresentativeUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_SalesRepresentativeDataTable 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 UpdateSalesRepresentativeRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateSalesRepresentativeRequest 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/Pipeline/SalesRepresentative/Components/_SalesRepresentativeCreateForm.razor b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeCreateForm.razor new file mode 100644 index 0000000..e7ffaf9 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeCreateForm.razor @@ -0,0 +1,110 @@ +@using Indotalent.Features.Pipeline.SalesRepresentative +@using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesRepresentativeService SalesRepresentativeService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Representative + Register a new sales representative and assign them to a team. +
+
+
+ + + + + + Basic Information + + + + Representative Name + + + + Sales Team + + @foreach (var item in _lookupData.SalesTeams) + { + @item.Name + } + + + + Job Title + + + + Employee Number + + + + + Contact Details + + + + Phone Number + + + + Email Address + + + + Description / Notes + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Representative + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateSalesRepresentativeValidator _validator = new(); + private CreateSalesRepresentativeRequest _model = new(); + private LookupSalesRepresentativeResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await SalesRepresentativeService.GetSalesRepresentativeLookupAsync(); + 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 SalesRepresentativeService.CreateSalesRepresentativeAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Representative created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeDataTable.razor b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeDataTable.razor new file mode 100644 index 0000000..4069ddd --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeDataTable.razor @@ -0,0 +1,280 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.SalesRepresentative +@using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesRepresentativeService SalesRepresentativeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Representative + Manage your sales person records and team assignments. +
+
+ + / + Pipeline + / + Representative +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedData != null) + { + View + Edit + Remove + + } + else + { + + Add New Representative + + } +
+
+ + + + + + Name + + + Sales Team + + Job Title + Contact Info + + + + + + +
+ @context.Name + @context.AutoNumber +
+
+ @context.SalesTeamName + @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 _representatives = new(); + private GetSalesRepresentativeListResponse? _selectedData; + 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; + _selectedData = null; + StateHasChanged(); + try + { + var response = await SalesRepresentativeService.GetSalesRepresentativeListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _representatives = response.Value ?? new List(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _representatives; + return _representatives.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmailAddress?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SalesTeamName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedData = 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("Representatives"); + worksheet.Cell(1, 1).Value = "Name"; + worksheet.Cell(1, 2).Value = "Sales Team"; + worksheet.Cell(1, 3).Value = "Employee Number"; + 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.SalesTeamName; + worksheet.Cell(currentRow, 3).Value = item.EmployeeNumber; + 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", "SalesRepresentative_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; _selectedData = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedData = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedData != null) { var req = await MapToUpdateRequest(_selectedData.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedData != null) { var req = await MapToUpdateRequest(_selectedData.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await SalesRepresentativeService.GetSalesRepresentativeByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateSalesRepresentativeRequest { Id = d.Id, Name = d.Name, JobTitle = d.JobTitle, EmployeeNumber = d.EmployeeNumber, PhoneNumber = d.PhoneNumber, EmailAddress = d.EmailAddress, Description = d.Description, SalesTeamId = d.SalesTeamId, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedData == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedData.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 SalesRepresentativeService.DeleteSalesRepresentativeByIdAsync(_selectedData.Id); + if (success) { _selectedData = null; await LoadData(); Snackbar.Add("Representative deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeUpdateForm.razor b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeUpdateForm.razor new file mode 100644 index 0000000..fdc587b --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Components/_SalesRepresentativeUpdateForm.razor @@ -0,0 +1,152 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.SalesRepresentative +@using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesRepresentativeService SalesRepresentativeService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Representative Profile" : "Edit Representative") + @(ReadOnly ? "Viewing personal profile and team assignment." : "Modify representative details.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + Basic Information + + + + Representative Name + + + + Sales Team + + @foreach (var item in _lookupData.SalesTeams) + { + @item.Name + } + + + + Job Title + + + + Employee Number + + + + + Contact Details + + + + 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 UpdateSalesRepresentativeRequest 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 UpdateSalesRepresentativeValidator _validator = new(); + private UpdateSalesRepresentativeRequest _model = new(); + private LookupSalesRepresentativeResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await SalesRepresentativeService.GetSalesRepresentativeLookupAsync(); + 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 SalesRepresentativeService.UpdateSalesRepresentativeAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Representative updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeHandler.cs new file mode 100644 index 0000000..4bf3253 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class CreateSalesRepresentativeRequest +{ + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? SalesTeamId { get; set; } +} + +public class CreateSalesRepresentativeResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateSalesRepresentativeCommand(CreateSalesRepresentativeRequest Data) : IRequest; + +public class CreateSalesRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateSalesRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesRepresentativeCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.SalesRepresentative); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.SalesRepresentative + { + AutoNumber = autoNo, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + EmployeeNumber = request.Data.EmployeeNumber, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description, + SalesTeamId = request.Data.SalesTeamId + }; + + _context.SalesRepresentative.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesRepresentativeResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeValidator.cs b/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeValidator.cs new file mode 100644 index 0000000..06b0ddc --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/CreateSalesRepresentativeValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class CreateSalesRepresentativeValidator : AbstractValidator +{ + public CreateSalesRepresentativeValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Representative Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SalesTeamId) + .NotEmpty().WithMessage("Sales Team is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/DeleteSalesRepresentativeByIdHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/DeleteSalesRepresentativeByIdHandler.cs new file mode 100644 index 0000000..e013396 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/DeleteSalesRepresentativeByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public record DeleteSalesRepresentativeByIdRequest(string Id); + +public record DeleteSalesRepresentativeByIdCommand(DeleteSalesRepresentativeByIdRequest Data) : IRequest; + +public class DeleteSalesRepresentativeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSalesRepresentativeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesRepresentativeByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesRepresentative + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.SalesRepresentative.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeByIdHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeByIdHandler.cs new file mode 100644 index 0000000..b457c15 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeByIdHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class GetSalesRepresentativeByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetSalesRepresentativeByIdQuery(string Id) : IRequest; + +public class GetSalesRepresentativeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSalesRepresentativeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesRepresentativeByIdQuery request, CancellationToken cancellationToken) + { + return await _context.SalesRepresentative + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetSalesRepresentativeByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + EmployeeNumber = x.EmployeeNumber, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + Description = x.Description, + SalesTeamId = x.SalesTeamId, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeListHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeListHandler.cs new file mode 100644 index 0000000..cf926c8 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/GetSalesRepresentativeListHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class GetSalesRepresentativeListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? SalesTeamName { get; set; } +} + +public record GetSalesRepresentativeListQuery() : IRequest>; + +public class GetSalesRepresentativeListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSalesRepresentativeListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSalesRepresentativeListQuery request, CancellationToken cancellationToken) + { + return await _context.SalesRepresentative + .AsNoTracking() + .Include(x => x.SalesTeam) + .OrderBy(x => x.Name) + .Select(x => new GetSalesRepresentativeListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Name = x.Name, + JobTitle = x.JobTitle, + EmployeeNumber = x.EmployeeNumber, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + SalesTeamName = x.SalesTeam != null ? x.SalesTeam.Name : string.Empty + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/LookupSalesRepresentativeHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/LookupSalesRepresentativeHandler.cs new file mode 100644 index 0000000..4700e79 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/LookupSalesRepresentativeHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class LookupSalesRepresentativeResponse +{ + public List SalesTeams { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupSalesRepresentativeQuery() : IRequest; + +public class LookupSalesRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupSalesRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupSalesRepresentativeQuery request, CancellationToken cancellationToken) + { + var result = new LookupSalesRepresentativeResponse(); + + result.SalesTeams = await _context.SalesTeam + .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/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeHandler.cs b/Features/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeHandler.cs new file mode 100644 index 0000000..d7b9689 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class UpdateSalesRepresentativeRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } + public string? SalesTeamId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateSalesRepresentativeResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateSalesRepresentativeCommand(UpdateSalesRepresentativeRequest Data) : IRequest; + +public class UpdateSalesRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateSalesRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesRepresentativeCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesRepresentative + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateSalesRepresentativeResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.EmployeeNumber = request.Data.EmployeeNumber; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + entity.SalesTeamId = request.Data.SalesTeamId; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateSalesRepresentativeResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeValidator.cs b/Features/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeValidator.cs new file mode 100644 index 0000000..0baa694 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/Cqrs/UpdateSalesRepresentativeValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; + +public class UpdateSalesRepresentativeValidator : AbstractValidator +{ + public UpdateSalesRepresentativeValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Representative Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SalesTeamId) + .NotEmpty().WithMessage("Sales Team is required"); + + RuleFor(x => x.EmailAddress) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.EmailAddress)); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/SalesRepresentativeEndpoint.cs b/Features/Pipeline/SalesRepresentative/SalesRepresentativeEndpoint.cs new file mode 100644 index 0000000..88e0a60 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/SalesRepresentativeEndpoint.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.SalesRepresentative; + +public static class SalesRepresentativeEndpoint +{ + public static void MapSalesRepresentativeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-representative").WithTags("SalesRepresentatives") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesRepresentativeListQuery()); + + return result.ToApiResponse("Sales representative list retrieved successfully"); + }) + .WithName("GetSalesRepresentativeList") + .WithTags("SalesRepresentatives"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesRepresentativeByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Sales representative detail retrieved successfully" + : $"Sales representative with ID {id} not found"); + }) + .WithName("GetSalesRepresentativeById") + .WithTags("SalesRepresentatives"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupSalesRepresentativeQuery()); + + return result.ToApiResponse("Sales representative lookup data retrieved successfully"); + }) + .WithName("GetSalesRepresentativeLookup") + .WithTags("SalesRepresentatives"); + + group.MapPost("/", async (CreateSalesRepresentativeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesRepresentativeCommand(request)); + + return result.ToApiResponse("Sales representative has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSalesRepresentative") + .WithTags("SalesRepresentatives"); + + group.MapPost("/update", async (UpdateSalesRepresentativeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesRepresentativeCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The representative data could not be found."); + } + + return result.ToApiResponse("Sales representative has been updated successfully"); + }) + .WithName("UpdateSalesRepresentative") + .WithTags("SalesRepresentatives"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesRepresentativeByIdCommand(new DeleteSalesRepresentativeByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The representative data could not be found."); + } + + return true.ToApiResponse("Sales representative has been deleted successfully"); + }) + .WithName("DeleteSalesRepresentativeById") + .WithTags("SalesRepresentatives"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesRepresentative/SalesRepresentativeService.cs b/Features/Pipeline/SalesRepresentative/SalesRepresentativeService.cs new file mode 100644 index 0000000..4bc31d3 --- /dev/null +++ b/Features/Pipeline/SalesRepresentative/SalesRepresentativeService.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.SalesRepresentative.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.SalesRepresentative; + +public class SalesRepresentativeService : BaseService +{ + private readonly RestClient _client; + + public SalesRepresentativeService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesRepresentativeListAsync() + { + var request = new RestRequest("api/sales-representative", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSalesRepresentativeByIdAsync(string id) + { + var request = new RestRequest($"api/sales-representative/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesRepresentativeLookupAsync() + { + var request = new RestRequest("api/sales-representative/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesRepresentativeAsync(CreateSalesRepresentativeRequest data) + { + var request = new RestRequest("api/sales-representative", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesRepresentativeByIdAsync(string id) + { + var request = new RestRequest($"api/sales-representative/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateSalesRepresentativeAsync(UpdateSalesRepresentativeRequest data) + { + var request = new RestRequest("api/sales-representative/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/SalesTeamPage.razor b/Features/Pipeline/SalesTeam/Components/SalesTeamPage.razor new file mode 100644 index 0000000..e136e9f --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/SalesTeamPage.razor @@ -0,0 +1,28 @@ +@page "/pipeline/sales-team" +@using Indotalent.Features.Pipeline.SalesTeam +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using Indotalent.Features.Pipeline.SalesTeam.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SalesTeamCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SalesTeamUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_SalesTeamDataTable 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 UpdateSalesTeamRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateSalesTeamRequest 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/Pipeline/SalesTeam/Components/_SalesRepresentativeCreateForm.razor b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeCreateForm.razor new file mode 100644 index 0000000..df280c2 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeCreateForm.razor @@ -0,0 +1,81 @@ +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using MudBlazor +@inject SalesTeamService SalesTeamService +@inject ISnackbar Snackbar + + + + + + + Full Name + + + + Job Title + + + + Employee Number + + + + Phone + + + + Email + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Add Member + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string SalesTeamId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateSalesTeamRepresentativeRequest _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.SalesTeamId = SalesTeamId; + try + { + var response = await SalesTeamService.CreateSalesTeamRepresentativeAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Member added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeDataTable.razor b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeDataTable.razor new file mode 100644 index 0000000..4ced8af --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeDataTable.razor @@ -0,0 +1,101 @@ +@using Indotalent.Features.Pipeline.SalesTeam +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using MudBlazor +@using Features.Root.Shared +@inject SalesTeamService SalesTeamService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Team Representatives + @if (!ReadOnly) + { + + Add Representative + + } +
+ + + + 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 SalesTeamId { get; set; } = string.Empty; + [Parameter] public List Representatives { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["SalesTeamId"] = SalesTeamId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesRepresentativeCreateForm>("Add Team Representative", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(SalesRepresentativeItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesRepresentativeUpdateForm>("Edit Representative", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(SalesRepresentativeItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove {item.Name} from this team?" }; + 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 SalesTeamService.DeleteSalesTeamRepresentativeAsync(item.Id!); + if (success) + { + Snackbar.Add("Representative removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeUpdateForm.razor b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeUpdateForm.razor new file mode 100644 index 0000000..c21a8aa --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesRepresentativeUpdateForm.razor @@ -0,0 +1,91 @@ +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using MudBlazor +@inject SalesTeamService SalesTeamService +@inject ISnackbar Snackbar + + + + + + + Full Name + + + + Job Title + + + + Employee Number + + + + Phone + + + + Email + + + + Description + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public SalesRepresentativeItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateSalesTeamRepresentativeRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.Name = Data.Name; + _model.JobTitle = Data.JobTitle; + _model.EmployeeNumber = Data.EmployeeNumber; + _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 SalesTeamService.UpdateSalesTeamRepresentativeAsync(_model); + await Task.Delay(500); + if (response != null && response.Value!.Success) + { + Snackbar.Add("Member updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/_SalesTeamCreateForm.razor b/Features/Pipeline/SalesTeam/Components/_SalesTeamCreateForm.razor new file mode 100644 index 0000000..fc9caac --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesTeamCreateForm.razor @@ -0,0 +1,72 @@ +@using Indotalent.Features.Pipeline.SalesTeam +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesTeamService SalesTeamService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Sales Team + Define a new sales unit for your organizational structure. +
+
+
+ + + + + + Team Configuration + + + + Team Name + + + + Description + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Team + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateSalesTeamValidator _validator = new(); + private CreateSalesTeamRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesTeamService.CreateSalesTeamAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Sales team created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/_SalesTeamDataTable.razor b/Features/Pipeline/SalesTeam/Components/_SalesTeamDataTable.razor new file mode 100644 index 0000000..5214b17 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesTeamDataTable.razor @@ -0,0 +1,276 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.SalesTeam +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesTeamService SalesTeamService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Team + Manage your organizational sales units and teams. +
+
+ + / + Pipeline + / + Sales Team +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedTeam != null) + { + View + Edit + Remove + + } + else + { + + Add New Team + + } +
+
+ + + + + + Team 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 _teams = new(); + private GetSalesTeamListResponse? _selectedTeam; + 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; + _selectedTeam = null; + StateHasChanged(); + try + { + var response = await SalesTeamService.GetSalesTeamListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _teams = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _teams; + return _teams.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; _selectedTeam = 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("SalesTeams"); + worksheet.Cell(1, 1).Value = "Team 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", "SalesTeam_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; _selectedTeam = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedTeam = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedTeam != null) { var req = await MapToUpdateRequest(_selectedTeam.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedTeam != null) { var req = await MapToUpdateRequest(_selectedTeam.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await SalesTeamService.GetSalesTeamByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateSalesTeamRequest { 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 (_selectedTeam == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedTeam.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 SalesTeamService.DeleteSalesTeamByIdAsync(_selectedTeam.Id); + if (isSuccess) { _selectedTeam = null; await LoadData(); Snackbar.Add("Sales team deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Components/_SalesTeamUpdateForm.razor b/Features/Pipeline/SalesTeam/Components/_SalesTeamUpdateForm.razor new file mode 100644 index 0000000..d02febc --- /dev/null +++ b/Features/Pipeline/SalesTeam/Components/_SalesTeamUpdateForm.razor @@ -0,0 +1,106 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Pipeline.SalesTeam +@using Indotalent.Features.Pipeline.SalesTeam.Cqrs +@using Indotalent.Features.Pipeline.SalesTeam.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesTeamService SalesTeamService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Team Details" : "Edit Team") + @(ReadOnly ? "Viewing sales team configuration." : "Modify existing team information.") +
+
+
+ + + + + + Team Configuration + + + + Team Name + + + + Description + + + + + <_SalesRepresentativeDataTable Representatives="_model.Representatives" SalesTeamId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshData" /> + + + 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 UpdateSalesTeamRequest 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 UpdateSalesTeamValidator _validator = new(); + private GetSalesTeamByIdResponse _model = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + await RefreshData(); + } + + private async Task RefreshData() + { + var response = await SalesTeamService.GetSalesTeamByIdAsync(Data.Id!); + if (response != null && response.IsSuccess) + { + _model = response.Value!; + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var updateReq = new UpdateSalesTeamRequest { Id = _model.Id, Name = _model.Name, Description = _model.Description }; + var response = await SalesTeamService.UpdateSalesTeamAsync(updateReq); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Sales team updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamHandler.cs new file mode 100644 index 0000000..d26c0a1 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamHandler.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.Pipeline.SalesTeam.Cqrs; + +public class CreateSalesTeamRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } +} + +public class CreateSalesTeamResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateSalesTeamCommand(CreateSalesTeamRequest Data) : IRequest; + +public class CreateSalesTeamHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateSalesTeamHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesTeamCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.SalesTeam + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Sales Team", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.SalesTeam + { + Name = request.Data.Name, + Description = request.Data.Description + }; + + _context.SalesTeam.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesTeamResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamRepresentativeHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamRepresentativeHandler.cs new file mode 100644 index 0000000..99b1c52 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamRepresentativeHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class CreateSalesTeamRepresentativeRequest +{ + public string? SalesTeamId { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class CreateSalesTeamRepresentativeResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateSalesTeamRepresentativeCommand(CreateSalesTeamRepresentativeRequest Data) : IRequest; + +public class CreateSalesTeamRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateSalesTeamRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesTeamRepresentativeCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.SalesRepresentative); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.SalesRepresentative + { + AutoNumber = autoNo, + SalesTeamId = request.Data.SalesTeamId, + Name = request.Data.Name, + JobTitle = request.Data.JobTitle, + EmployeeNumber = request.Data.EmployeeNumber, + PhoneNumber = request.Data.PhoneNumber, + EmailAddress = request.Data.EmailAddress, + Description = request.Data.Description + }; + + _context.SalesRepresentative.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesTeamRepresentativeResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamValidator.cs b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamValidator.cs new file mode 100644 index 0000000..eff5628 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/CreateSalesTeamValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class CreateSalesTeamValidator : AbstractValidator +{ + public CreateSalesTeamValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Team Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamByIdHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamByIdHandler.cs new file mode 100644 index 0000000..ba9e972 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public record DeleteSalesTeamByIdRequest(string Id); + +public record DeleteSalesTeamByIdCommand(DeleteSalesTeamByIdRequest Data) : IRequest; + +public class DeleteSalesTeamByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSalesTeamByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesTeamByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesTeam + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.SalesTeam.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamRepresentativeHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamRepresentativeHandler.cs new file mode 100644 index 0000000..1a78fae --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/DeleteSalesTeamRepresentativeHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public record DeleteSalesTeamRepresentativeCommand(string Id) : IRequest; + +public class DeleteSalesTeamRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSalesTeamRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesTeamRepresentativeCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesRepresentative + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) + { + return false; + } + + _context.SalesRepresentative.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamByIdHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamByIdHandler.cs new file mode 100644 index 0000000..8f26688 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamByIdHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class SalesRepresentativeItemResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? AutoNumber { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class GetSalesTeamByIdResponse +{ + 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 List Representatives { get; set; } = new(); +} + +public record GetSalesTeamByIdQuery(string Id) : IRequest; + +public class GetSalesTeamByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSalesTeamByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesTeamByIdQuery request, CancellationToken cancellationToken) + { + return await _context.SalesTeam + .AsNoTracking() + .Include(x => x.SalesRepresentativeList) + .Where(x => x.Id == request.Id) + .Select(x => new GetSalesTeamByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Representatives = x.SalesRepresentativeList + .OrderBy(r => r.Name) + .Select(r => new SalesRepresentativeItemResponse + { + Id = r.Id, + Name = r.Name, + AutoNumber = r.AutoNumber, + JobTitle = r.JobTitle, + EmployeeNumber = r.EmployeeNumber, + PhoneNumber = r.PhoneNumber, + EmailAddress = r.EmailAddress, + Description = r.Description + }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamListHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamListHandler.cs new file mode 100644 index 0000000..f68212c --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/GetSalesTeamListHandler.cs @@ -0,0 +1,35 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class GetSalesTeamListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } +} + +public record GetSalesTeamListQuery() : IRequest>; + +public class GetSalesTeamListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSalesTeamListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSalesTeamListQuery request, CancellationToken cancellationToken) + { + return await _context.SalesTeam + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetSalesTeamListResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamHandler.cs new file mode 100644 index 0000000..1d6751e --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class UpdateSalesTeamRequest +{ + 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 UpdateSalesTeamResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateSalesTeamCommand(UpdateSalesTeamRequest Data) : IRequest; + +public class UpdateSalesTeamHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateSalesTeamHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesTeamCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.SalesTeam + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Sales Team", request.Data.Name ?? string.Empty); + } + + var entity = await _context.SalesTeam + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateSalesTeamResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateSalesTeamResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamRepresentativeHandler.cs b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamRepresentativeHandler.cs new file mode 100644 index 0000000..10dab04 --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamRepresentativeHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class UpdateSalesTeamRepresentativeRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? JobTitle { get; set; } + public string? EmployeeNumber { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Description { get; set; } +} + +public class UpdateSalesTeamRepresentativeResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateSalesTeamRepresentativeCommand(UpdateSalesTeamRepresentativeRequest Data) : IRequest; + +public class UpdateSalesTeamRepresentativeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateSalesTeamRepresentativeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesTeamRepresentativeCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesRepresentative + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateSalesTeamRepresentativeResponse + { + Id = request.Data.Id, + Success = false + }; + } + + entity.Name = request.Data.Name; + entity.JobTitle = request.Data.JobTitle; + entity.EmployeeNumber = request.Data.EmployeeNumber; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Description = request.Data.Description; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateSalesTeamRepresentativeResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamValidator.cs b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamValidator.cs new file mode 100644 index 0000000..e8fc96a --- /dev/null +++ b/Features/Pipeline/SalesTeam/Cqrs/UpdateSalesTeamValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Pipeline.SalesTeam.Cqrs; + +public class UpdateSalesTeamValidator : AbstractValidator +{ + public UpdateSalesTeamValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Team Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Description) + .MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/SalesTeamEndpoint.cs b/Features/Pipeline/SalesTeam/SalesTeamEndpoint.cs new file mode 100644 index 0000000..98bff0d --- /dev/null +++ b/Features/Pipeline/SalesTeam/SalesTeamEndpoint.cs @@ -0,0 +1,110 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Pipeline.SalesTeam.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Pipeline.SalesTeam; + +public static class SalesTeamEndpoint +{ + public static void MapSalesTeamEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-team").WithTags("SalesTeams") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesTeamListQuery()); + + return result.ToApiResponse("Sales team list retrieved successfully"); + }) + .WithName("GetSalesTeamList") + .WithTags("SalesTeams"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesTeamByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Sales team detail retrieved successfully" + : $"Sales team with ID {id} not found"); + }) + .WithName("GetSalesTeamById") + .WithTags("SalesTeams"); + + group.MapPost("/", async (CreateSalesTeamRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesTeamCommand(request)); + + return result.ToApiResponse("Sales team has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSalesTeam") + .WithTags("SalesTeams"); + + group.MapPost("/update", async (UpdateSalesTeamRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesTeamCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The sales team data could not be found."); + } + + return result.ToApiResponse("Sales team has been updated successfully"); + }) + .WithName("UpdateSalesTeam") + .WithTags("SalesTeams"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesTeamByIdCommand(new DeleteSalesTeamByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The sales team data could not be found."); + } + + return true.ToApiResponse("Sales team has been deleted successfully"); + }) + .WithName("DeleteSalesTeamById") + .WithTags("SalesTeams"); + + group.MapPost("/representative", async (CreateSalesTeamRepresentativeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesTeamRepresentativeCommand(request)); + + return result.ToApiResponse("Representative has been added to team successfully"); + }) + .WithName("CreateSalesTeamRepresentative") + .WithTags("SalesTeams"); + + group.MapPost("/representative/update", async (UpdateSalesTeamRepresentativeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesTeamRepresentativeCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Representative data not found."); + } + + return result.ToApiResponse("Representative has been updated successfully"); + }) + .WithName("UpdateSalesTeamRepresentative") + .WithTags("SalesTeams"); + + group.MapPost("/representative/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesTeamRepresentativeCommand(id)); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Representative data not found."); + } + + return true.ToApiResponse("Representative has been removed from team successfully"); + }) + .WithName("DeleteSalesTeamRepresentative") + .WithTags("SalesTeams"); + } +} \ No newline at end of file diff --git a/Features/Pipeline/SalesTeam/SalesTeamService.cs b/Features/Pipeline/SalesTeam/SalesTeamService.cs new file mode 100644 index 0000000..5bc9caa --- /dev/null +++ b/Features/Pipeline/SalesTeam/SalesTeamService.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Pipeline.SalesTeam.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Pipeline.SalesTeam; + +public class SalesTeamService : BaseService +{ + private readonly RestClient _client; + + public SalesTeamService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesTeamListAsync() + { + var request = new RestRequest("api/sales-team", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSalesTeamByIdAsync(string id) + { + var request = new RestRequest($"api/sales-team/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesTeamAsync(CreateSalesTeamRequest data) + { + var request = new RestRequest("api/sales-team", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesTeamByIdAsync(string id) + { + var request = new RestRequest($"api/sales-team/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateSalesTeamAsync(UpdateSalesTeamRequest data) + { + var request = new RestRequest("api/sales-team/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesTeamRepresentativeAsync(CreateSalesTeamRepresentativeRequest data) + { + var request = new RestRequest("api/sales-team/representative", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesTeamRepresentativeAsync(UpdateSalesTeamRepresentativeRequest data) + { + var request = new RestRequest("api/sales-team/representative/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesTeamRepresentativeAsync(string id) + { + var request = new RestRequest($"api/sales-team/representative/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ 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..3c0beb7 --- /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..109d247 --- /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..f749ab7 --- /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..fde06dc --- /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..35b0302 --- /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..2ea7a7b --- /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..33e5713 --- /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..2a566a4 --- /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..47072a6 --- /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..1a682d9 --- /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/DebitNote/Components/DebitNotePage.razor b/Features/Purchase/DebitNote/Components/DebitNotePage.razor new file mode 100644 index 0000000..715c303 --- /dev/null +++ b/Features/Purchase/DebitNote/Components/DebitNotePage.razor @@ -0,0 +1,51 @@ +@page "/purchase/debit-note" +@using Indotalent.Features.Purchase.DebitNote.Cqrs +@using Indotalent.Features.Purchase.DebitNote.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_DebitNoteCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_DebitNoteUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_DebitNoteDataTable 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 UpdateDebitNoteRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateDebitNoteRequest 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/DebitNote/Components/_DebitNoteCreateForm.razor b/Features/Purchase/DebitNote/Components/_DebitNoteCreateForm.razor new file mode 100644 index 0000000..3801db3 --- /dev/null +++ b/Features/Purchase/DebitNote/Components/_DebitNoteCreateForm.razor @@ -0,0 +1,177 @@ +@using Indotalent.Features.Purchase.DebitNote.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DebitNoteService DebitNoteService +@inject ISnackbar Snackbar + + +
+ +
+ Add Debit Note + Create a new commercial adjustment for purchase return. +
+
+
+ + + + + Basic Information + + + Debit Note Date + + + + + Purchase Return Reference + + @foreach (var item in _lookup.PurchaseReturns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_DebitNoteItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Adjustment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Debit + @_afterTax.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Debit Note + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateDebitNoteRequest _model = new() { DebitNoteDate = DateTime.Today, DebitNoteStatus = Indotalent.Data.Enums.DebitNoteStatus.Draft }; + private DebitNoteLookupResponse _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 DebitNoteService.GetDebitNoteLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnPurchaseReturnChanged(string prId) + { + _model.PurchaseReturnId = prId; + if (string.IsNullOrEmpty(prId)) + { + _items.Clear(); + _beforeTax = 0; + _taxAmount = 0; + _afterTax = 0; + return; + } + + var res = await DebitNoteService.GetPurchaseReturnDetailForDebitNoteAsync(prId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _beforeTax = res.Value.BeforeTaxAmount; + _taxAmount = res.Value.TaxAmount; + _afterTax = res.Value.AfterTaxAmount; + Snackbar.Add("Purchase Return data and inventory transactions loaded.", Severity.Info); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await DebitNoteService.CreateDebitNoteAsync(_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/Purchase/DebitNote/Components/_DebitNoteDataTable.razor b/Features/Purchase/DebitNote/Components/_DebitNoteDataTable.razor new file mode 100644 index 0000000..aa5397c --- /dev/null +++ b/Features/Purchase/DebitNote/Components/_DebitNoteDataTable.razor @@ -0,0 +1,377 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.DebitNote +@using Indotalent.Features.Purchase.DebitNote.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DebitNoteService DebitNoteService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Debit Note + Manage debit notes for purchase returns and vendor adjustments. +
+
+ + / + Purchase + / + Debit Note +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Debit Note + + } +
+
+ + + + + + DN Number + + + Date + + + PR Ref + + + Vendor + + + Status + + + + + + + @context.AutoNumber + @context.DebitNoteDate?.ToString("yyyy-MM-dd") + @context.PurchaseReturnNumber + @context.VendorName + + @context.DebitNoteStatus.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 GetDebitNoteListResponse? _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 DebitNoteService.GetDebitNoteListAsync(); + 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.PurchaseReturnNumber?.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("DebitNotes"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "DN Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "PR Ref"; + worksheet.Cell(currentRow, 4).Value = "Vendor"; + 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.DebitNoteDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.PurchaseReturnNumber; + worksheet.Cell(currentRow, 4).Value = item.VendorName; + worksheet.Cell(currentRow, 5).Value = item.DebitNoteStatus.ToString(); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "DebitNote_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 DebitNoteService.GetDebitNoteByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateDebitNoteRequest + { + Id = detail.Id, + DebitNoteDate = detail.DebitNoteDate, + DebitNoteStatus = detail.DebitNoteStatus, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + PurchaseReturnId = detail.PurchaseReturnId, + BeforeTaxAmount = detail.BeforeTaxAmount, + TaxAmount = detail.TaxAmount, + AfterTaxAmount = detail.AfterTaxAmount, + 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 DebitNoteService.DeleteDebitNoteByIdAsync(_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/DebitNote/Components/_DebitNoteItemDataTable.razor b/Features/Purchase/DebitNote/Components/_DebitNoteItemDataTable.razor new file mode 100644 index 0000000..6586eb1 --- /dev/null +++ b/Features/Purchase/DebitNote/Components/_DebitNoteItemDataTable.razor @@ -0,0 +1,33 @@ +@using Indotalent.Features.Purchase.DebitNote.Cqrs +@using MudBlazor + + +
+ Returned Inventory Items +
+ + + + Product + Unit Price + Qty Returned + Total Adjustment + + + @context.ProductName + @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/DebitNote/Components/_DebitNoteUpdateForm.razor b/Features/Purchase/DebitNote/Components/_DebitNoteUpdateForm.razor new file mode 100644 index 0000000..6c03e5e --- /dev/null +++ b/Features/Purchase/DebitNote/Components/_DebitNoteUpdateForm.razor @@ -0,0 +1,262 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.DebitNote.Cqrs +@using Indotalent.Features.Purchase.DebitNote.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject DebitNoteService DebitNoteService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Debit Note + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Debit Note Date + + + + + Purchase Return Reference + + @foreach (var item in _lookup.PurchaseReturns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_DebitNoteItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Adjustment Summary +
+ Sub Total + @_model.BeforeTaxAmount?.ToString("N2") +
+
+ Tax Amount + @_model.TaxAmount?.ToString("N2") +
+ +
+ Total Debit + @_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 UpdateDebitNoteRequest 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 UpdateDebitNoteRequest _model = new(); + private List _items = new(); + private DebitNoteLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await DebitNoteService.GetDebitNoteLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnPurchaseReturnChanged(string prId) + { + if (ReadOnly || _model.PurchaseReturnId == prId) return; + _model.PurchaseReturnId = prId; + await RefreshItems(); + Snackbar.Add("Purchase Return reference changed. Items and totals recalculated.", Severity.Info); + } + + private async Task RefreshItems() + { + if (string.IsNullOrEmpty(_model.PurchaseReturnId)) return; + + try + { + var response = await DebitNoteService.GetPurchaseReturnDetailForDebitNoteAsync(_model.PurchaseReturnId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items; + _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 DebitNoteService.GetDebitNoteByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = DebitNotePdfGenerator.Generate(response.Value); + var fileName = $"DN_{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 DebitNoteService.UpdateDebitNoteAsync(_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/DebitNote/Cqrs/CreateDebitNoteHandler.cs b/Features/Purchase/DebitNote/Cqrs/CreateDebitNoteHandler.cs new file mode 100644 index 0000000..1a824bc --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/CreateDebitNoteHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class CreateDebitNoteRequest +{ + public DateTime? DebitNoteDate { get; set; } + public Data.Enums.DebitNoteStatus DebitNoteStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseReturnId { get; set; } +} + +public class CreateDebitNoteResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateDebitNoteCommand(CreateDebitNoteRequest Data) : IRequest; + +public class CreateDebitNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateDebitNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDebitNoteCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.DebitNote); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"DN/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.DebitNote + { + AutoNumber = autoNo, + DebitNoteDate = request.Data.DebitNoteDate, + DebitNoteStatus = request.Data.DebitNoteStatus, + Description = request.Data.Description, + PurchaseReturnId = request.Data.PurchaseReturnId + }; + + _context.DebitNote.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateDebitNoteResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/CreateDebitNoteValidator.cs b/Features/Purchase/DebitNote/Cqrs/CreateDebitNoteValidator.cs new file mode 100644 index 0000000..83dcc9e --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/CreateDebitNoteValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class CreateDebitNoteValidator : AbstractValidator +{ + public CreateDebitNoteValidator() + { + RuleFor(x => x.DebitNoteDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.PurchaseReturnId).NotEmpty().WithMessage("Purchase Return reference is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/DeleteDebitNoteByIdHandler.cs b/Features/Purchase/DebitNote/Cqrs/DeleteDebitNoteByIdHandler.cs new file mode 100644 index 0000000..966b5a4 --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/DeleteDebitNoteByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public record DeleteDebitNoteByIdCommand(string Id) : IRequest; + +public class DeleteDebitNoteByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteDebitNoteByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDebitNoteByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.DebitNote + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.DebitNote.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/GetDebitNoteByIdHandler.cs b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteByIdHandler.cs new file mode 100644 index 0000000..73dc701 --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteByIdHandler.cs @@ -0,0 +1,135 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class DebitNoteItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public decimal UnitPrice { get; set; } + public double Quantity { get; set; } + public decimal Total { get; set; } +} + +public class GetDebitNoteByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? DebitNoteDate { get; set; } + public Data.Enums.DebitNoteStatus DebitNoteStatus { get; set; } + public string? Description { get; set; } + public string? PurchaseReturnId { get; set; } + public string? PurchaseReturnNumber { 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 GetDebitNoteByIdQuery(string Id) : IRequest; + +public class GetDebitNoteByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetDebitNoteByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDebitNoteByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + var debitNote = await _context.DebitNote + .AsNoTracking() + .Include(x => x.PurchaseReturn) + .ThenInclude(pr => pr!.GoodsReceive) + .ThenInclude(gr => gr!.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .Include(x => x.PurchaseReturn) + .ThenInclude(pr => pr!.GoodsReceive) + .ThenInclude(gr => gr!.PurchaseOrder) + .ThenInclude(po => po!.Tax) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (debitNote == null) return null; + + var purchaseOrder = debitNote.PurchaseReturn?.GoodsReceive?.PurchaseOrder; + var taxRate = purchaseOrder?.Tax?.PercentageValue ?? 0; + + var transactions = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == debitNote.PurchaseReturnId && x.ModuleName == nameof(Data.Entities.PurchaseReturn)) + .ToListAsync(cancellationToken); + + var items = new List(); + decimal subTotal = 0; + + foreach (var trans in transactions) + { + var poItem = await _context.PurchaseOrderItem + .AsNoTracking() + .FirstOrDefaultAsync(x => x.PurchaseOrderId == purchaseOrder!.Id && x.ProductId == trans.ProductId, cancellationToken); + + var unitPrice = poItem?.UnitPrice ?? 0; + var qty = Math.Abs(trans.Movement ?? 0); + var total = unitPrice * (decimal)qty; + + items.Add(new DebitNoteItemResponse + { + Id = trans.Id, + ProductId = trans.ProductId, + ProductName = trans.Product?.Name, + UnitPrice = unitPrice, + Quantity = qty, + Total = total + }); + + subTotal += total; + } + + var taxAmount = subTotal * (decimal)(taxRate / 100); + + return new GetDebitNoteByIdResponse + { + Id = debitNote.Id, + AutoNumber = debitNote.AutoNumber, + DebitNoteDate = debitNote.DebitNoteDate, + DebitNoteStatus = debitNote.DebitNoteStatus, + Description = debitNote.Description, + PurchaseReturnId = debitNote.PurchaseReturnId, + PurchaseReturnNumber = debitNote.PurchaseReturn?.AutoNumber, + 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", + BeforeTaxAmount = subTotal, + TaxAmount = taxAmount, + AfterTaxAmount = subTotal + taxAmount, + CreatedAt = debitNote.CreatedAt, + CreatedBy = debitNote.CreatedBy, + UpdatedAt = debitNote.UpdatedAt, + UpdatedBy = debitNote.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/GetDebitNoteListHandler.cs b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteListHandler.cs new file mode 100644 index 0000000..9cd346f --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class GetDebitNoteListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? DebitNoteDate { get; set; } + public string? PurchaseReturnNumber { get; set; } + public string? VendorName { get; set; } + public Data.Enums.DebitNoteStatus DebitNoteStatus { get; set; } +} + +public record GetDebitNoteListQuery() : IRequest>; + +public class GetDebitNoteListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetDebitNoteListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetDebitNoteListQuery request, CancellationToken cancellationToken) + { + return await _context.DebitNote + .AsNoTracking() + .Include(x => x.PurchaseReturn) + .ThenInclude(pr => pr!.GoodsReceive) + .ThenInclude(gr => gr!.PurchaseOrder) + .ThenInclude(po => po!.Vendor) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetDebitNoteListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + DebitNoteDate = x.DebitNoteDate, + PurchaseReturnNumber = x.PurchaseReturn!.AutoNumber, + VendorName = x.PurchaseReturn!.GoodsReceive!.PurchaseOrder!.Vendor!.Name, + DebitNoteStatus = x.DebitNoteStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/GetDebitNoteLookupHandler.cs b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteLookupHandler.cs new file mode 100644 index 0000000..16b3e8e --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/GetDebitNoteLookupHandler.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.DebitNote.Cqrs; + +public class DebitNoteLookupResponse +{ + public List PurchaseReturns { 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 GetDebitNoteLookupQuery() : IRequest; + +public class GetDebitNoteLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetDebitNoteLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDebitNoteLookupQuery request, CancellationToken cancellationToken) + { + var response = new DebitNoteLookupResponse(); + + response.PurchaseReturns = await _context.PurchaseReturn.AsNoTracking() + .Where(x => x.Status == PurchaseReturnStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(DebitNoteStatus)) + .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/DebitNote/Cqrs/GetPurchaseReturnDetailForDebitNoteHandler.cs b/Features/Purchase/DebitNote/Cqrs/GetPurchaseReturnDetailForDebitNoteHandler.cs new file mode 100644 index 0000000..221efcc --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/GetPurchaseReturnDetailForDebitNoteHandler.cs @@ -0,0 +1,72 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public record GetPurchaseReturnDetailForDebitNoteQuery(string PurchaseReturnId) : IRequest; + +public class GetPurchaseReturnDetailForDebitNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseReturnDetailForDebitNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseReturnDetailForDebitNoteQuery request, CancellationToken cancellationToken) + { + var purchaseReturn = await _context.PurchaseReturn + .AsNoTracking() + .Include(x => x.GoodsReceive) + .ThenInclude(gr => gr!.PurchaseOrder) + .ThenInclude(po => po!.Tax) + .FirstOrDefaultAsync(x => x.Id == request.PurchaseReturnId, cancellationToken); + + if (purchaseReturn == null) return null; + + var purchaseOrder = purchaseReturn.GoodsReceive?.PurchaseOrder; + var taxRate = purchaseOrder?.Tax?.PercentageValue ?? 0; + + var transactions = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.PurchaseReturnId && x.ModuleName == nameof(Data.Entities.PurchaseReturn)) + .ToListAsync(cancellationToken); + + var items = new List(); + decimal subTotal = 0; + + foreach (var trans in transactions) + { + var poItem = await _context.PurchaseOrderItem + .AsNoTracking() + .FirstOrDefaultAsync(x => x.PurchaseOrderId == purchaseOrder!.Id && x.ProductId == trans.ProductId, cancellationToken); + + var unitPrice = poItem?.UnitPrice ?? 0; + var qty = Math.Abs(trans.Movement ?? 0); + var total = unitPrice * (decimal)qty; + + items.Add(new DebitNoteItemResponse + { + Id = trans.Id, + ProductId = trans.ProductId, + ProductName = trans.Product?.Name, + UnitPrice = unitPrice, + Quantity = qty, + Total = total + }); + + subTotal += total; + } + + var taxAmount = subTotal * (decimal)(taxRate / 100); + + return new GetDebitNoteByIdResponse + { + PurchaseReturnId = purchaseReturn.Id, + PurchaseReturnNumber = purchaseReturn.AutoNumber, + BeforeTaxAmount = subTotal, + TaxAmount = taxAmount, + AfterTaxAmount = subTotal + taxAmount, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteHandler.cs b/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteHandler.cs new file mode 100644 index 0000000..a2eaa16 --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class UpdateDebitNoteRequest +{ + public string? Id { get; set; } + public DateTime? DebitNoteDate { get; set; } + public Data.Enums.DebitNoteStatus DebitNoteStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? PurchaseReturnId { 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 UpdateDebitNoteCommand(UpdateDebitNoteRequest Data) : IRequest; + +public class UpdateDebitNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateDebitNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDebitNoteCommand request, CancellationToken cancellationToken) + { + var entity = await _context.DebitNote + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.DebitNoteDate = request.Data.DebitNoteDate; + entity.DebitNoteStatus = request.Data.DebitNoteStatus; + entity.Description = request.Data.Description; + entity.PurchaseReturnId = request.Data.PurchaseReturnId; + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteValidator.cs b/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteValidator.cs new file mode 100644 index 0000000..9015ce7 --- /dev/null +++ b/Features/Purchase/DebitNote/Cqrs/UpdateDebitNoteValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.DebitNote.Cqrs; + +public class UpdateDebitNoteValidator : AbstractValidator +{ + public UpdateDebitNoteValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.PurchaseReturnId).NotEmpty().WithMessage("Purchase Return reference is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/DebitNoteEndpoint.cs b/Features/Purchase/DebitNote/DebitNoteEndpoint.cs new file mode 100644 index 0000000..cabfdec --- /dev/null +++ b/Features/Purchase/DebitNote/DebitNoteEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.DebitNote.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.DebitNote; + +public static class DebitNoteEndpoint +{ + public static void MapDebitNoteEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/debit-note").WithTags("DebitNotes") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDebitNoteListQuery()); + return result.ToApiResponse("Debit note list retrieved successfully"); + }) + .WithName("GetDebitNoteList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetDebitNoteByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Debit note detail retrieved successfully" + : $"Debit note with ID {id} not found"); + }) + .WithName("GetDebitNoteById"); + + group.MapGet("/purchase-return-detail/{prId}", async (string prId, IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReturnDetailForDebitNoteQuery(prId)); + return result.ToApiResponse(result is not null + ? "Purchase return detail for debit note retrieved successfully" + : $"Purchase return with ID {prId} not found"); + }) + .WithName("GetPurchaseReturnDetailForDebitNote"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDebitNoteLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetDebitNoteLookup"); + + group.MapPost("/", async (CreateDebitNoteRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateDebitNoteCommand(request)); + return result.ToApiResponse("Debit note has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateDebitNote"); + + group.MapPost("/update", async (UpdateDebitNoteRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateDebitNoteCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Debit note not found."); + } + return result.ToApiResponse("Debit note has been updated successfully"); + }) + .WithName("UpdateDebitNote"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteDebitNoteByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Debit note not found."); + } + return true.ToApiResponse("Debit note has been deleted successfully"); + }) + .WithName("DeleteDebitNoteById"); + } +} \ No newline at end of file diff --git a/Features/Purchase/DebitNote/DebitNotePdfGenerator.cs b/Features/Purchase/DebitNote/DebitNotePdfGenerator.cs new file mode 100644 index 0000000..bcb3b47 --- /dev/null +++ b/Features/Purchase/DebitNote/DebitNotePdfGenerator.cs @@ -0,0 +1,126 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.DebitNote.Cqrs; + +namespace Indotalent.Features.Purchase.DebitNote; + +public class DebitNotePdfGenerator +{ + public static byte[] Generate(GetDebitNoteByIdResponse 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("DEBIT NOTE") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + col.Item().Text($"PR Ref: {data.PurchaseReturnNumber}").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("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("DOCUMENT DETAILS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Debit Note Date"); + r.RelativeItem().AlignRight().Text(data.DebitNoteDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Status"); + r.RelativeItem().AlignRight().Text(data.DebitNoteStatus.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("Returned 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).Text(item.ProductName).Bold(); + 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/DebitNote/DebitNoteService.cs b/Features/Purchase/DebitNote/DebitNoteService.cs new file mode 100644 index 0000000..d8ee13c --- /dev/null +++ b/Features/Purchase/DebitNote/DebitNoteService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.DebitNote.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.DebitNote; + +public class DebitNoteService : BaseService +{ + private readonly RestClient _client; + + public DebitNoteService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDebitNoteListAsync() + { + var request = new RestRequest("api/debit-note", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetDebitNoteByIdAsync(string id) + { + var request = new RestRequest($"api/debit-note/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPurchaseReturnDetailForDebitNoteAsync(string prId) + { + var request = new RestRequest($"api/debit-note/purchase-return-detail/{prId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetDebitNoteLookupAsync() + { + var request = new RestRequest("api/debit-note/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateDebitNoteAsync(CreateDebitNoteRequest data) + { + var request = new RestRequest("api/debit-note", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateDebitNoteAsync(UpdateDebitNoteRequest data) + { + var request = new RestRequest("api/debit-note/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDebitNoteByIdAsync(string id) + { + var request = new RestRequest($"api/debit-note/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/GoodsReceive/Components/GoodsReceivePage.razor b/Features/Purchase/GoodsReceive/Components/GoodsReceivePage.razor new file mode 100644 index 0000000..0f9ff93 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/GoodsReceivePage.razor @@ -0,0 +1,51 @@ +@page "/purchase/goods-receive" +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using Indotalent.Features.Purchase.GoodsReceive.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_GoodsReceiveCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_GoodsReceiveUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_GoodsReceiveDataTable 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 UpdateGoodsReceiveRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateGoodsReceiveRequest 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/GoodsReceive/Components/_GoodsReceiveCreateForm.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveCreateForm.razor new file mode 100644 index 0000000..302bcf3 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject GoodsReceiveService GoodsReceiveService +@inject ISnackbar Snackbar + + +
+ +
+ Add Goods Receive + Record incoming goods from a purchase order. +
+
+
+ + + + + Basic Information + + + Receive Date + + + + + Purchase Order + + @foreach (var item in _lookup.PurchaseOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Goods Receive + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateGoodsReceiveValidator _validator = new(); + private CreateGoodsReceiveRequest _model = new() { ReceiveDate = DateTime.Today }; + private GoodsReceiveLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await GoodsReceiveService.GetGoodsReceiveLookupAsync(); + 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 GoodsReceiveService.CreateGoodsReceiveAsync(_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/GoodsReceive/Components/_GoodsReceiveDataTable.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveDataTable.razor new file mode 100644 index 0000000..698919b --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveDataTable.razor @@ -0,0 +1,378 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.GoodsReceive +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject GoodsReceiveService GoodsReceiveService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Goods Receive + Manage incoming goods from purchase orders and inventory updates. +
+
+ + / + Purchase + / + Goods Receive +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Goods Receive + + } +
+
+ + + + + + Number + + + Date + + + PO Number + + + Status + + + + + + + @context.AutoNumber + @context.ReceiveDate?.ToString("yyyy-MM-dd") + @context.PurchaseOrderNumber + + @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 GetGoodsReceiveListResponse? _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 GoodsReceiveService.GetGoodsReceiveListAsync(); + 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.PurchaseOrderNumber?.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("GoodsReceives"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Auto Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "PO Number"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.ReceiveDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.PurchaseOrderNumber; + worksheet.Cell(currentRow, 4).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", "GR_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 GoodsReceiveService.GetGoodsReceiveByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateGoodsReceiveRequest + { + Id = detail.Id, + ReceiveDate = detail.ReceiveDate, + Status = detail.Status, + Description = detail.Description, + PurchaseOrderId = detail.PurchaseOrderId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 GoodsReceiveService.DeleteGoodsReceiveByIdAsync(_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/GoodsReceive/Components/_GoodsReceiveItemCreateForm.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemCreateForm.razor new file mode 100644 index 0000000..c1c8127 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using MudBlazor +@inject GoodsReceiveService GoodsReceiveService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Movement Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Transaction + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string GoodsReceiveId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateGoodsReceiveItemRequest _model = new() { Movement = 1 }; + private GoodsReceiveLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await GoodsReceiveService.GetGoodsReceiveLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.GoodsReceiveId = GoodsReceiveId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await GoodsReceiveService.CreateGoodsReceiveItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Transaction added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemDataTable.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemDataTable.razor new file mode 100644 index 0000000..3f3a667 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.GoodsReceive +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject GoodsReceiveService GoodsReceiveService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Received Items (Transactions) + @if (!ReadOnly) + { + + Add Transaction + + } +
+ + + + Product + Warehouse + Movement Qty + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string GoodsReceiveId { 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 { ["GoodsReceiveId"] = GoodsReceiveId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_GoodsReceiveItemCreateForm>("Add Inventory Transaction", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(GoodsReceiveInvenTransResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_GoodsReceiveItemUpdateForm>("Edit Inventory Transaction", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(GoodsReceiveInvenTransResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this transaction?" }; + 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 GoodsReceiveService.DeleteGoodsReceiveItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Transaction removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemUpdateForm.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemUpdateForm.razor new file mode 100644 index 0000000..ee3330e --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using MudBlazor +@inject GoodsReceiveService GoodsReceiveService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Movement Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public GoodsReceiveInvenTransResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateGoodsReceiveItemRequest _model = new(); + private GoodsReceiveLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await GoodsReceiveService.GetGoodsReceiveLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await GoodsReceiveService.UpdateGoodsReceiveItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Transaction updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Components/_GoodsReceiveUpdateForm.razor b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveUpdateForm.razor new file mode 100644 index 0000000..2514e74 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Components/_GoodsReceiveUpdateForm.razor @@ -0,0 +1,263 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.GoodsReceive.Cqrs +@using Indotalent.Features.Purchase.GoodsReceive.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject GoodsReceiveService GoodsReceiveService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Goods Receive + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Receive Date + + + + + Purchase Order + + @foreach (var item in _lookup.PurchaseOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_GoodsReceiveItemDataTable Items="_items" GoodsReceiveId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Items In + @_items.Count +
+
+ Total Movement Qty + @_items.Sum(x => x.Movement) +
+ +
+ Movement Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdateGoodsReceiveRequest 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 UpdateGoodsReceiveValidator _validator = new(); + private UpdateGoodsReceiveRequest _model = new(); + private List _items = new(); + private GoodsReceiveLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await GoodsReceiveService.GetGoodsReceiveLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await GoodsReceiveService.GetGoodsReceiveByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 GoodsReceiveService.GetGoodsReceiveByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = GoodsReceivePdfGenerator.Generate(response.Value); + var fileName = $"GR_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await GoodsReceiveService.UpdateGoodsReceiveAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/GoodsReceive/Cqrs/CreateGoodsReceiveHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveHandler.cs new file mode 100644 index 0000000..a2a41ba --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class CreateGoodsReceiveRequest +{ + public DateTime? ReceiveDate { get; set; } + public Data.Enums.GoodsReceiveStatus Status { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } +} + +public class CreateGoodsReceiveResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateGoodsReceiveCommand(CreateGoodsReceiveRequest Data) : IRequest; + +public class CreateGoodsReceiveHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateGoodsReceiveHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateGoodsReceiveCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.GoodsReceive); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.GoodsReceive + { + AutoNumber = autoNo, + ReceiveDate = request.Data.ReceiveDate, + Status = request.Data.Status, + Description = request.Data.Description, + PurchaseOrderId = request.Data.PurchaseOrderId + }; + + _context.GoodsReceive.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateGoodsReceiveResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveItemHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveItemHandler.cs new file mode 100644 index 0000000..645f17b --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class CreateGoodsReceiveItemRequest +{ + public string? GoodsReceiveId { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateGoodsReceiveItemCommand(CreateGoodsReceiveItemRequest Data) : IRequest; + +public class CreateGoodsReceiveItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateGoodsReceiveItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateGoodsReceiveItemCommand request, CancellationToken cancellationToken) + { + var parent = await _context.GoodsReceive + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.GoodsReceiveId, cancellationToken); + + if (parent == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + AutoNumber = autoNo, + ModuleId = parent.Id, + ModuleName = nameof(Data.Entities.GoodsReceive), + ModuleCode = "GR", + ModuleNumber = parent.AutoNumber, + MovementDate = parent.ReceiveDate ?? DateTime.Now, + Status = (Data.Enums.InventoryTransactionStatus)parent.Status, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveValidator.cs b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveValidator.cs new file mode 100644 index 0000000..3bbcd67 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/CreateGoodsReceiveValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class CreateGoodsReceiveValidator : AbstractValidator +{ + public CreateGoodsReceiveValidator() + { + RuleFor(x => x.ReceiveDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.PurchaseOrderId).NotEmpty().WithMessage("Purchase Order is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveByIdHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveByIdHandler.cs new file mode 100644 index 0000000..06e9eab --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveByIdHandler.cs @@ -0,0 +1,31 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public record DeleteGoodsReceiveByIdCommand(string Id) : IRequest; + +public class DeleteGoodsReceiveByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteGoodsReceiveByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteGoodsReceiveByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.GoodsReceive + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.GoodsReceive)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(transactions); + _context.GoodsReceive.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveItemHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveItemHandler.cs new file mode 100644 index 0000000..fd5155f --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/DeleteGoodsReceiveItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public record DeleteGoodsReceiveItemCommand(string Id) : IRequest; + +public class DeleteGoodsReceiveItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteGoodsReceiveItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteGoodsReceiveItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveByIdHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveByIdHandler.cs new file mode 100644 index 0000000..066d8c5 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class GoodsReceiveInvenTransResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetGoodsReceiveByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReceiveDate { get; set; } + public Data.Enums.GoodsReceiveStatus Status { get; set; } + public string? Description { get; set; } + public string? PurchaseOrderId { get; set; } + public string? PurchaseOrderNumber { 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 GetGoodsReceiveByIdQuery(string Id) : IRequest; + +public class GetGoodsReceiveByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetGoodsReceiveByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetGoodsReceiveByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.GoodsReceive + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var details = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.GoodsReceive)) + .Select(x => new GoodsReceiveInvenTransResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }) + .ToListAsync(cancellationToken); + + return new GetGoodsReceiveByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + ReceiveDate = master.ReceiveDate, + Status = master.Status, + Description = master.Description, + PurchaseOrderId = master.PurchaseOrderId, + PurchaseOrderNumber = master.PurchaseOrder?.AutoNumber, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = details + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveListHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveListHandler.cs new file mode 100644 index 0000000..6f12b3d --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveListHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class GetGoodsReceiveListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReceiveDate { get; set; } + public string? PurchaseOrderNumber { get; set; } + public Data.Enums.GoodsReceiveStatus Status { get; set; } +} + +public record GetGoodsReceiveListQuery() : IRequest>; + +public class GetGoodsReceiveListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetGoodsReceiveListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetGoodsReceiveListQuery request, CancellationToken cancellationToken) + { + return await _context.GoodsReceive + .AsNoTracking() + .Include(x => x.PurchaseOrder) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetGoodsReceiveListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + ReceiveDate = x.ReceiveDate, + PurchaseOrderNumber = x.PurchaseOrder!.AutoNumber, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveLookupHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveLookupHandler.cs new file mode 100644 index 0000000..9575fb3 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/GetGoodsReceiveLookupHandler.cs @@ -0,0 +1,68 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class GoodsReceiveLookupResponse +{ + public List PurchaseOrders { get; set; } = new(); + public List Warehouses { 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 GetGoodsReceiveLookupQuery() : IRequest; + +public class GetGoodsReceiveLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetGoodsReceiveLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetGoodsReceiveLookupQuery request, CancellationToken cancellationToken) + { + var response = new GoodsReceiveLookupResponse(); + + 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.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Products = await _context.Product.AsNoTracking() + .Where(x => x.Physical == true) + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(GoodsReceiveStatus)) + .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/GoodsReceive/Cqrs/UpdateGoodsReceiveHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveHandler.cs new file mode 100644 index 0000000..3c2a8c8 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class UpdateGoodsReceiveRequest +{ + public string? Id { get; set; } + public DateTime? ReceiveDate { get; set; } + public Data.Enums.GoodsReceiveStatus Status { 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 UpdateGoodsReceiveCommand(UpdateGoodsReceiveRequest Data) : IRequest; + +public class UpdateGoodsReceiveHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateGoodsReceiveHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateGoodsReceiveCommand request, CancellationToken cancellationToken) + { + var entity = await _context.GoodsReceive + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ReceiveDate = request.Data.ReceiveDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.PurchaseOrderId = request.Data.PurchaseOrderId; + + _context.GoodsReceive.Update(entity); + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.GoodsReceive)) + .ToListAsync(cancellationToken); + + foreach (var trans in transactions) + { + trans.MovementDate = entity.ReceiveDate; + trans.ModuleNumber = entity.AutoNumber; + trans.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + _context.CalculateInvenTrans(trans); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveItemHandler.cs b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveItemHandler.cs new file mode 100644 index 0000000..a8c84e7 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class UpdateGoodsReceiveItemRequest +{ + public string? Id { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateGoodsReceiveItemCommand(UpdateGoodsReceiveItemRequest Data) : IRequest; + +public class UpdateGoodsReceiveItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateGoodsReceiveItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateGoodsReceiveItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.WarehouseId = request.Data.WarehouseId; + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveValidator.cs b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveValidator.cs new file mode 100644 index 0000000..59f9b55 --- /dev/null +++ b/Features/Purchase/GoodsReceive/Cqrs/UpdateGoodsReceiveValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +public class UpdateGoodsReceiveValidator : AbstractValidator +{ + public UpdateGoodsReceiveValidator() + { + 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/GoodsReceive/GoodsReceiveEndpoint.cs b/Features/Purchase/GoodsReceive/GoodsReceiveEndpoint.cs new file mode 100644 index 0000000..41e2a3a --- /dev/null +++ b/Features/Purchase/GoodsReceive/GoodsReceiveEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.GoodsReceive.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.GoodsReceive; + +public static class GoodsReceiveEndpoint +{ + public static void MapGoodsReceiveEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/goods-receive").WithTags("GoodsReceives") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetGoodsReceiveListQuery()); + return result.ToApiResponse("Goods receive list retrieved successfully"); + }) + .WithName("GetGoodsReceiveList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetGoodsReceiveByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Goods receive detail retrieved successfully" + : $"Goods receive with ID {id} not found"); + }) + .WithName("GetGoodsReceiveById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetGoodsReceiveLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetGoodsReceiveLookup"); + + group.MapPost("/", async (CreateGoodsReceiveRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateGoodsReceiveCommand(request)); + return result.ToApiResponse("Goods receive has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateGoodsReceive"); + + group.MapPost("/update", async (UpdateGoodsReceiveRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateGoodsReceiveCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Goods receive not found."); + } + return result.ToApiResponse("Goods receive has been updated successfully"); + }) + .WithName("UpdateGoodsReceive"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteGoodsReceiveByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Goods receive not found."); + } + return true.ToApiResponse("Goods receive has been deleted successfully"); + }) + .WithName("DeleteGoodsReceiveById"); + + group.MapPost("/inventory-transaction", async (CreateGoodsReceiveItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateGoodsReceiveItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateGoodsReceiveItem"); + + group.MapPost("/inventory-transaction/update", async (UpdateGoodsReceiveItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateGoodsReceiveItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateGoodsReceiveItem"); + + group.MapPost("/inventory-transaction/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteGoodsReceiveItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteGoodsReceiveItem"); + } +} \ No newline at end of file diff --git a/Features/Purchase/GoodsReceive/GoodsReceivePdfGenerator.cs b/Features/Purchase/GoodsReceive/GoodsReceivePdfGenerator.cs new file mode 100644 index 0000000..5580a28 --- /dev/null +++ b/Features/Purchase/GoodsReceive/GoodsReceivePdfGenerator.cs @@ -0,0 +1,110 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.GoodsReceive.Cqrs; + +namespace Indotalent.Features.Purchase.GoodsReceive; + +public class GoodsReceivePdfGenerator +{ + public static byte[] Generate(GetGoodsReceiveByIdResponse 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("GOODS RECEIVE") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("INTERNAL COPY").FontSize(11).ExtraBold(); + col.Item().Text($"PO Reference: {data.PurchaseOrderNumber}").FontColor(textSlate); + col.Item().Text($"Date: {data.ReceiveDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().PaddingTop(10).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Name"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Received Qty"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(20).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); + c.Item().PaddingTop(10).Text($"Status: {data.Status.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryBlue); + }); + + row.ConstantItem(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Received By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse Staff )").FontSize(7); + }); + }); + }); + + 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/GoodsReceive/GoodsReceiveService.cs b/Features/Purchase/GoodsReceive/GoodsReceiveService.cs new file mode 100644 index 0000000..dc04db0 --- /dev/null +++ b/Features/Purchase/GoodsReceive/GoodsReceiveService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.GoodsReceive.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.GoodsReceive; + +public class GoodsReceiveService : BaseService +{ + private readonly RestClient _client; + + public GoodsReceiveService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetGoodsReceiveListAsync() + { + var request = new RestRequest("api/goods-receive", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetGoodsReceiveByIdAsync(string id) + { + var request = new RestRequest($"api/goods-receive/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetGoodsReceiveLookupAsync() + { + var request = new RestRequest("api/goods-receive/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateGoodsReceiveAsync(CreateGoodsReceiveRequest data) + { + var request = new RestRequest("api/goods-receive", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateGoodsReceiveAsync(UpdateGoodsReceiveRequest data) + { + var request = new RestRequest("api/goods-receive/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteGoodsReceiveByIdAsync(string id) + { + var request = new RestRequest($"api/goods-receive/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateGoodsReceiveItemAsync(CreateGoodsReceiveItemRequest data) + { + var request = new RestRequest("api/goods-receive/inventory-transaction", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateGoodsReceiveItemAsync(UpdateGoodsReceiveItemRequest data) + { + var request = new RestRequest("api/goods-receive/inventory-transaction/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteGoodsReceiveItemAsync(string id) + { + var request = new RestRequest($"api/goods-receive/inventory-transaction/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/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..e4823ea --- /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..7d8654d --- /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..d33122f --- /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..3b88089 --- /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..81f4f66 --- /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..f24bd74 --- /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..43b8803 --- /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..3e27055 --- /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..dcccd54 --- /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..737a0da --- /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..1f432bc --- /dev/null +++ b/Features/Purchase/PurchasePage.razor @@ -0,0 +1,147 @@ +@page "/purchase" +@using Indotalent.Features.Purchase.Bill.Components +@using Indotalent.Features.Purchase.BillReport.Components +@using Indotalent.Features.Purchase.DebitNote.Components +@using Indotalent.Features.Purchase.GoodsReceive.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 Indotalent.Features.Purchase.PurchaseRequisition.Components +@using Indotalent.Features.Purchase.PurchaseReturn.Components +@using Indotalent.Features.Purchase.PurchaseReturnReport.Components +@using Indotalent.Features.Purchase.ReceiveReport.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, "requisition" }, + { 1, "order" }, + { 2, "receive" }, + { 3, "return" }, + { 4, "bill" }, + { 5, "debit-note" }, + { 6, "payment" }, + { 7, "report-purchase" }, + { 8, "report-receive" }, + { 9, "report-return" }, + { 10, "report-bill" }, + { 11, "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..4cb23e7 --- /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/Purchase/PurchaseRequisition/Components/PurchaseRequisitionPage.razor b/Features/Purchase/PurchaseRequisition/Components/PurchaseRequisitionPage.razor new file mode 100644 index 0000000..adf3ce9 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/PurchaseRequisitionPage.razor @@ -0,0 +1,51 @@ +@page "/purchase/purchase-requisition" +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using Indotalent.Features.Purchase.PurchaseRequisition.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PurchaseRequisitionCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PurchaseRequisitionUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PurchaseRequisitionDataTable 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 UpdatePurchaseRequisitionRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePurchaseRequisitionRequest 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/PurchaseRequisition/Components/_PurchaseRequisitionCreateForm.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionCreateForm.razor new file mode 100644 index 0000000..5aa5252 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionCreateForm.razor @@ -0,0 +1,141 @@ +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PurchaseRequisitionService PurchaseRequisitionService +@inject ISnackbar Snackbar + + +
+ +
+ Add Purchase Requisition + Create a new master purchase requisition. +
+
+
+ + + + + Basic Information + + + Requisition 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 Requisition + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePurchaseRequisitionValidator _validator = new(); + private CreatePurchaseRequisitionRequest _model = new() { RequisitionDate = DateTime.Today }; + private PurchaseRequisitionLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseRequisitionService.GetPurchaseRequisitionLookupAsync(); + 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 PurchaseRequisitionService.CreatePurchaseRequisitionAsync(_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/PurchaseRequisition/Components/_PurchaseRequisitionDataTable.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionDataTable.razor new file mode 100644 index 0000000..c6d3533 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionDataTable.razor @@ -0,0 +1,373 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseRequisition +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PurchaseRequisitionService PurchaseRequisitionService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Purchase Requisition + Manage procurement requests and approval flows. +
+
+ + / + Purchase + / + Purchase Requisition +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Purchase Requisition + + } +
+
+ + + + + + No + + + Date + + + Vendor + + + Status + + + Total Amount + + + + + + + @context.AutoNumber + @context.RequisitionDate?.ToString("yyyy-MM-dd") + @context.VendorName + + @context.RequisitionStatus.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 GetPurchaseRequisitionListResponse? _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 PurchaseRequisitionService.GetPurchaseRequisitionListAsync(); + 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("PurchaseRequisitions"); + 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.RequisitionDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.VendorName; + worksheet.Cell(currentRow, 4).Value = item.RequisitionStatus.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", "PR_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 PurchaseRequisitionService.GetPurchaseRequisitionByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePurchaseRequisitionRequest + { + Id = detail.Id, + RequisitionDate = detail.RequisitionDate, + RequisitionStatus = detail.RequisitionStatus, + 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 PurchaseRequisitionService.DeletePurchaseRequisitionByIdAsync(_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/PurchaseRequisition/Components/_PurchaseRequisitionItemCreateForm.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemCreateForm.razor new file mode 100644 index 0000000..91b31c1 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemCreateForm.razor @@ -0,0 +1,86 @@ +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using MudBlazor +@inject PurchaseRequisitionService PurchaseRequisitionService +@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 PurchaseRequisitionId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreatePurchaseRequisitionItemRequest _model = new() { Quantity = 1 }; + private PurchaseRequisitionLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseRequisitionService.GetPurchaseRequisitionLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.PurchaseRequisitionId = PurchaseRequisitionId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PurchaseRequisitionService.CreatePurchaseRequisitionItemAsync(_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/PurchaseRequisition/Components/_PurchaseRequisitionItemDataTable.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemDataTable.razor new file mode 100644 index 0000000..db6be60 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemDataTable.razor @@ -0,0 +1,103 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseRequisition +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject PurchaseRequisitionService PurchaseRequisitionService +@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 PurchaseRequisitionId { 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 { ["PurchaseRequisitionId"] = PurchaseRequisitionId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseRequisitionItemCreateForm>("Add Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(PurchaseRequisitionItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseRequisitionItemUpdateForm>("Edit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(PurchaseRequisitionItemResponse 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 PurchaseRequisitionService.DeletePurchaseRequisitionItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemUpdateForm.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemUpdateForm.razor new file mode 100644 index 0000000..382e87d --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionItemUpdateForm.razor @@ -0,0 +1,96 @@ +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using MudBlazor +@inject PurchaseRequisitionService PurchaseRequisitionService +@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 PurchaseRequisitionItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdatePurchaseRequisitionItemRequest _model = new(); + private PurchaseRequisitionLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseRequisitionService.GetPurchaseRequisitionLookupAsync(); + 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 PurchaseRequisitionService.UpdatePurchaseRequisitionItemAsync(_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/PurchaseRequisition/Components/_PurchaseRequisitionUpdateForm.razor b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionUpdateForm.razor new file mode 100644 index 0000000..650c522 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Components/_PurchaseRequisitionUpdateForm.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs +@using Indotalent.Features.Purchase.PurchaseRequisition.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PurchaseRequisitionService PurchaseRequisitionService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Requisition + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Requisition 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 + + + + + <_PurchaseRequisitionItemDataTable Items="_items" PurchaseRequisitionId="@(_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 UpdatePurchaseRequisitionRequest 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 UpdatePurchaseRequisitionValidator _validator = new(); + private UpdatePurchaseRequisitionRequest _model = new(); + private List _items = new(); + private PurchaseRequisitionLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PurchaseRequisitionService.GetPurchaseRequisitionLookupAsync(); + 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 PurchaseRequisitionService.GetPurchaseRequisitionByIdAsync(_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 PurchaseRequisitionService.GetPurchaseRequisitionByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PurchaseRequisitionPdfGenerator.Generate(response.Value); + var fileName = $"PR_{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 PurchaseRequisitionService.UpdatePurchaseRequisitionAsync(_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/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionHandler.cs new file mode 100644 index 0000000..96fb062 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class CreatePurchaseRequisitionRequest +{ + public DateTime? RequisitionDate { get; set; } + public Data.Enums.PurchaseRequisitionStatus RequisitionStatus { get; set; } + public string? Description { get; set; } + public string? VendorId { get; set; } + public string? TaxId { get; set; } +} + +public class CreatePurchaseRequisitionResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePurchaseRequisitionCommand(CreatePurchaseRequisitionRequest Data) : IRequest; + +public class CreatePurchaseRequisitionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseRequisitionHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseRequisitionCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PurchaseRequisition); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"PR/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PurchaseRequisition + { + AutoNumber = autoNo, + RequisitionDate = request.Data.RequisitionDate, + RequisitionStatus = request.Data.RequisitionStatus, + Description = request.Data.Description, + VendorId = request.Data.VendorId, + TaxId = request.Data.TaxId, + BeforeTaxAmount = 0, + TaxAmount = 0, + AfterTaxAmount = 0 + }; + + _context.PurchaseRequisition.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePurchaseRequisitionResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionItemHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionItemHandler.cs new file mode 100644 index 0000000..27e0ca9 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionItemHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class CreatePurchaseRequisitionItemRequest +{ + public string? PurchaseRequisitionId { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record CreatePurchaseRequisitionItemCommand(CreatePurchaseRequisitionItemRequest Data) : IRequest; + +public class CreatePurchaseRequisitionItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseRequisitionItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseRequisitionItemCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.PurchaseRequisitionItem + { + PurchaseRequisitionId = request.Data.PurchaseRequisitionId, + 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.PurchaseRequisitionItem.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(request.Data.PurchaseRequisitionId)) + { + PurchaseRequisitionHelper.Recalculate(_context, request.Data.PurchaseRequisitionId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionValidator.cs b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionValidator.cs new file mode 100644 index 0000000..d9bb6b5 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/CreatePurchaseRequisitionValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class CreatePurchaseRequisitionValidator : AbstractValidator +{ + public CreatePurchaseRequisitionValidator() + { + RuleFor(x => x.RequisitionDate).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/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionByIdHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionByIdHandler.cs new file mode 100644 index 0000000..d4cf613 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public record DeletePurchaseRequisitionByIdCommand(string Id) : IRequest; + +public class DeletePurchaseRequisitionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseRequisitionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseRequisitionByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseRequisition + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.PurchaseRequisition.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionItemHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionItemHandler.cs new file mode 100644 index 0000000..a6df806 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/DeletePurchaseRequisitionItemHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public record DeletePurchaseRequisitionItemCommand(string Id) : IRequest; + +public class DeletePurchaseRequisitionItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseRequisitionItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseRequisitionItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseRequisitionItem + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var requisitionId = entity.PurchaseRequisitionId; + + _context.PurchaseRequisitionItem.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(requisitionId)) + { + PurchaseRequisitionHelper.Recalculate(_context, requisitionId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionByIdHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionByIdHandler.cs new file mode 100644 index 0000000..166a9e3 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionByIdHandler.cs @@ -0,0 +1,118 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class PurchaseRequisitionItemResponse +{ + 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 GetPurchaseRequisitionByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? RequisitionDate { get; set; } + public Data.Enums.PurchaseRequisitionStatus RequisitionStatus { 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 GetPurchaseRequisitionByIdQuery(string Id) : IRequest; + +public class GetPurchaseRequisitionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseRequisitionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseRequisitionByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.PurchaseRequisition + .AsNoTracking() + .Include(x => x.Vendor) + .Include(x => x.PurchaseRequisitionItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetPurchaseRequisitionByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + RequisitionDate = x.RequisitionDate, + RequisitionStatus = x.RequisitionStatus, + 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.PurchaseRequisitionItemList.Select(i => new PurchaseRequisitionItemResponse + { + 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/PurchaseRequisition/Cqrs/GetPurchaseRequisitionListHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionListHandler.cs new file mode 100644 index 0000000..6b114ed --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class GetPurchaseRequisitionListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? RequisitionDate { get; set; } + public string? VendorName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.PurchaseRequisitionStatus RequisitionStatus { get; set; } +} + +public record GetPurchaseRequisitionListQuery() : IRequest>; + +public class GetPurchaseRequisitionListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPurchaseRequisitionListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPurchaseRequisitionListQuery request, CancellationToken cancellationToken) + { + return await _context.PurchaseRequisition + .AsNoTracking() + .Include(x => x.Vendor) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPurchaseRequisitionListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + RequisitionDate = x.RequisitionDate, + VendorName = x.Vendor!.Name, + AfterTaxAmount = x.AfterTaxAmount, + RequisitionStatus = x.RequisitionStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionLookupHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionLookupHandler.cs new file mode 100644 index 0000000..bd96acf --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/GetPurchaseRequisitionLookupHandler.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.PurchaseRequisition.Cqrs; + +public class PurchaseRequisitionLookupResponse +{ + 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 GetPurchaseRequisitionLookupQuery() : IRequest; + +public class GetPurchaseRequisitionLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseRequisitionLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseRequisitionLookupQuery request, CancellationToken cancellationToken) + { + var response = new PurchaseRequisitionLookupResponse(); + + 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(PurchaseRequisitionStatus)) + .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/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionHandler.cs new file mode 100644 index 0000000..1a429f0 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class UpdatePurchaseRequisitionRequest +{ + public string? Id { get; set; } + public DateTime? RequisitionDate { get; set; } + public Data.Enums.PurchaseRequisitionStatus RequisitionStatus { 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 UpdatePurchaseRequisitionCommand(UpdatePurchaseRequisitionRequest Data) : IRequest; + +public class UpdatePurchaseRequisitionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseRequisitionHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseRequisitionCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseRequisition + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.RequisitionDate = request.Data.RequisitionDate; + entity.RequisitionStatus = request.Data.RequisitionStatus; + entity.Description = request.Data.Description; + entity.VendorId = request.Data.VendorId; + entity.TaxId = request.Data.TaxId; + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.Id)) + { + PurchaseRequisitionHelper.Recalculate(_context, entity.Id); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionItemHandler.cs b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionItemHandler.cs new file mode 100644 index 0000000..7df2b6e --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionItemHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class UpdatePurchaseRequisitionItemRequest +{ + 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 UpdatePurchaseRequisitionItemCommand(UpdatePurchaseRequisitionItemRequest Data) : IRequest; + +public class UpdatePurchaseRequisitionItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseRequisitionItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseRequisitionItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseRequisitionItem + .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.PurchaseRequisitionId)) + { + PurchaseRequisitionHelper.Recalculate(_context, entity.PurchaseRequisitionId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionValidator.cs b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionValidator.cs new file mode 100644 index 0000000..0d0516a --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/Cqrs/UpdatePurchaseRequisitionValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +public class UpdatePurchaseRequisitionValidator : AbstractValidator +{ + public UpdatePurchaseRequisitionValidator() + { + 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/PurchaseRequisition/PurchaseRequisitionEndpoint.cs b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionEndpoint.cs new file mode 100644 index 0000000..2038eb1 --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PurchaseRequisition; + +public static class PurchaseRequisitionEndpoint +{ + public static void MapPurchaseRequisitionEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/purchase-requisition").WithTags("PurchaseRequisitions") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseRequisitionListQuery()); + return result.ToApiResponse("Purchase requisition list retrieved successfully"); + }) + .WithName("GetPurchaseRequisitionList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseRequisitionByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Purchase requisition detail retrieved successfully" + : $"Purchase requisition with ID {id} not found"); + }) + .WithName("GetPurchaseRequisitionById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseRequisitionLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPurchaseRequisitionLookup"); + + group.MapPost("/", async (CreatePurchaseRequisitionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseRequisitionCommand(request)); + return result.ToApiResponse("Purchase requisition has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePurchaseRequisition"); + + group.MapPost("/update", async (UpdatePurchaseRequisitionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseRequisitionCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Purchase requisition not found."); + } + return result.ToApiResponse("Purchase requisition has been updated successfully"); + }) + .WithName("UpdatePurchaseRequisition"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseRequisitionByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Purchase requisition not found."); + } + return true.ToApiResponse("Purchase requisition has been deleted successfully"); + }) + .WithName("DeletePurchaseRequisitionById"); + + group.MapPost("/purchase-requisition-item", async (CreatePurchaseRequisitionItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseRequisitionItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreatePurchaseRequisitionItem"); + + group.MapPost("/purchase-requisition-item/update", async (UpdatePurchaseRequisitionItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseRequisitionItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdatePurchaseRequisitionItem"); + + group.MapPost("/purchase-requisition-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseRequisitionItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeletePurchaseRequisitionItem"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseRequisition/PurchaseRequisitionPdfGenerator.cs b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionPdfGenerator.cs new file mode 100644 index 0000000..f037adc --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionPdfGenerator.cs @@ -0,0 +1,153 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; + +namespace Indotalent.Features.Purchase.PurchaseRequisition; + +public class PurchaseRequisitionPdfGenerator +{ + public static byte[] Generate(GetPurchaseRequisitionByIdResponse 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 REQUISITION") + .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.RequisitionDate?.ToString("dd MMM yyyy")}").FontSize(9); + c.Item().Text($"Status: {data.RequisitionStatus.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/PurchaseRequisition/PurchaseRequisitionService.cs b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionService.cs new file mode 100644 index 0000000..0f97e0a --- /dev/null +++ b/Features/Purchase/PurchaseRequisition/PurchaseRequisitionService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PurchaseRequisition.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PurchaseRequisition; + +public class PurchaseRequisitionService : BaseService +{ + private readonly RestClient _client; + + public PurchaseRequisitionService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPurchaseRequisitionListAsync() + { + var request = new RestRequest("api/purchase-requisition", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPurchaseRequisitionByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-requisition/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPurchaseRequisitionLookupAsync() + { + var request = new RestRequest("api/purchase-requisition/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePurchaseRequisitionAsync(CreatePurchaseRequisitionRequest data) + { + var request = new RestRequest("api/purchase-requisition", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseRequisitionAsync(UpdatePurchaseRequisitionRequest data) + { + var request = new RestRequest("api/purchase-requisition/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseRequisitionByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-requisition/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreatePurchaseRequisitionItemAsync(CreatePurchaseRequisitionItemRequest data) + { + var request = new RestRequest("api/purchase-requisition/purchase-requisition-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseRequisitionItemAsync(UpdatePurchaseRequisitionItemRequest data) + { + var request = new RestRequest("api/purchase-requisition/purchase-requisition-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseRequisitionItemAsync(string id) + { + var request = new RestRequest($"api/purchase-requisition/purchase-requisition-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/PurchaseReturn/Components/PurchaseReturnPage.razor b/Features/Purchase/PurchaseReturn/Components/PurchaseReturnPage.razor new file mode 100644 index 0000000..cd9c107 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/PurchaseReturnPage.razor @@ -0,0 +1,51 @@ +@page "/purchase/purchase-return" +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using Indotalent.Features.Purchase.PurchaseReturn.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PurchaseReturnCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PurchaseReturnUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_PurchaseReturnDataTable 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 UpdatePurchaseReturnRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePurchaseReturnRequest 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/PurchaseReturn/Components/_PurchaseReturnCreateForm.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnCreateForm.razor new file mode 100644 index 0000000..8531f79 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject PurchaseReturnService PurchaseReturnService +@inject ISnackbar Snackbar + + +
+ +
+ Add Purchase Return + Initiate a return process from a confirmed goods receive. +
+
+
+ + + + + Basic Information + + + Return Date + + + + + Goods Receive Reference + + @foreach (var item in _lookup.GoodsReceives) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Reason + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Purchase Return + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePurchaseReturnValidator _validator = new(); + private CreatePurchaseReturnRequest _model = new() { ReturnDate = DateTime.Today }; + private PurchaseReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseReturnService.GetPurchaseReturnLookupAsync(); + 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 PurchaseReturnService.CreatePurchaseReturnAsync(_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/PurchaseReturn/Components/_PurchaseReturnDataTable.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnDataTable.razor new file mode 100644 index 0000000..6d696b2 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnDataTable.razor @@ -0,0 +1,378 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseReturn +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PurchaseReturnService PurchaseReturnService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Purchase Return + Manage returns of goods to vendors and inventory stock adjustments. +
+
+ + / + Purchase + / + Purchase Return +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Purchase Return + + } +
+
+ + + + + + Number + + + Date + + + GR Number + + + Status + + + + + + + @context.AutoNumber + @context.ReturnDate?.ToString("yyyy-MM-dd") + @context.GoodsReceiveNumber + + @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 GetPurchaseReturnListResponse? _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 PurchaseReturnService.GetPurchaseReturnListAsync(); + 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.GoodsReceiveNumber?.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("PurchaseReturns"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "GR Reference"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.ReturnDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.GoodsReceiveNumber; + worksheet.Cell(currentRow, 4).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", "PRN_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 PurchaseReturnService.GetPurchaseReturnByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdatePurchaseReturnRequest + { + Id = detail.Id, + ReturnDate = detail.ReturnDate, + Status = detail.Status, + Description = detail.Description, + GoodsReceiveId = detail.GoodsReceiveId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 PurchaseReturnService.DeletePurchaseReturnByIdAsync(_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/PurchaseReturn/Components/_PurchaseReturnItemCreateForm.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemCreateForm.razor new file mode 100644 index 0000000..4e34223 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using MudBlazor +@inject PurchaseReturnService PurchaseReturnService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Return Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Line + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string PurchaseReturnId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreatePurchaseReturnItemRequest _model = new() { Movement = 1 }; + private PurchaseReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseReturnService.GetPurchaseReturnLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.PurchaseReturnId = PurchaseReturnId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PurchaseReturnService.CreatePurchaseReturnItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemDataTable.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemDataTable.razor new file mode 100644 index 0000000..ec3eeee --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseReturn +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject PurchaseReturnService PurchaseReturnService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Stock Return Details + @if (!ReadOnly) + { + + Add Stock Line + + } +
+ + + + Product + Warehouse + Return Qty + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string PurchaseReturnId { 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 { ["PurchaseReturnId"] = PurchaseReturnId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseReturnItemCreateForm>("Add Stock Line", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(PurchaseReturnInvenTransResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_PurchaseReturnItemUpdateForm>("Edit Stock Line", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(PurchaseReturnInvenTransResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this stock return line?" }; + 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 PurchaseReturnService.DeletePurchaseReturnItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Line removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemUpdateForm.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemUpdateForm.razor new file mode 100644 index 0000000..fa9ab21 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using MudBlazor +@inject PurchaseReturnService PurchaseReturnService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Return Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public PurchaseReturnInvenTransResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdatePurchaseReturnItemRequest _model = new(); + private PurchaseReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await PurchaseReturnService.GetPurchaseReturnLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await PurchaseReturnService.UpdatePurchaseReturnItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnUpdateForm.razor b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnUpdateForm.razor new file mode 100644 index 0000000..60e3ce8 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Components/_PurchaseReturnUpdateForm.razor @@ -0,0 +1,263 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseReturn.Cqrs +@using Indotalent.Features.Purchase.PurchaseReturn.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject PurchaseReturnService PurchaseReturnService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Purchase Return + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Return Date + + + + + Goods Receive Reference + + @foreach (var item in _lookup.GoodsReceives) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Reason + + + + + <_PurchaseReturnItemDataTable Items="_items" PurchaseReturnId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Line Items + @_items.Count +
+
+ Total Return Quantity + @_items.Sum(x => x.Movement) +
+ +
+ Process Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdatePurchaseReturnRequest 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 UpdatePurchaseReturnValidator _validator = new(); + private UpdatePurchaseReturnRequest _model = new(); + private List _items = new(); + private PurchaseReturnLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await PurchaseReturnService.GetPurchaseReturnLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await PurchaseReturnService.GetPurchaseReturnByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 PurchaseReturnService.GetPurchaseReturnByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = PurchaseReturnPdfGenerator.Generate(response.Value); + var fileName = $"PRN_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await PurchaseReturnService.UpdatePurchaseReturnAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/PurchaseReturn/Cqrs/CreatePurchaseReturnHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnHandler.cs new file mode 100644 index 0000000..83c981d --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class CreatePurchaseReturnRequest +{ + public DateTime? ReturnDate { get; set; } + public Data.Enums.PurchaseReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? GoodsReceiveId { get; set; } +} + +public class CreatePurchaseReturnResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePurchaseReturnCommand(CreatePurchaseReturnRequest Data) : IRequest; + +public class CreatePurchaseReturnHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseReturnHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseReturnCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.PurchaseReturn); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.PurchaseReturn + { + AutoNumber = autoNo, + ReturnDate = request.Data.ReturnDate, + Status = request.Data.Status, + Description = request.Data.Description, + GoodsReceiveId = request.Data.GoodsReceiveId + }; + + _context.PurchaseReturn.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePurchaseReturnResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnItemHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnItemHandler.cs new file mode 100644 index 0000000..90233c0 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class CreatePurchaseReturnItemRequest +{ + public string? PurchaseReturnId { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreatePurchaseReturnItemCommand(CreatePurchaseReturnItemRequest Data) : IRequest; + +public class CreatePurchaseReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreatePurchaseReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePurchaseReturnItemCommand request, CancellationToken cancellationToken) + { + var parent = await _context.PurchaseReturn + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.PurchaseReturnId, cancellationToken); + + if (parent == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + AutoNumber = autoNo, + ModuleId = parent.Id, + ModuleName = nameof(Data.Entities.PurchaseReturn), + ModuleCode = "PRN", + ModuleNumber = parent.AutoNumber, + MovementDate = parent.ReturnDate ?? DateTime.Now, + Status = (Data.Enums.InventoryTransactionStatus)parent.Status, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnValidator.cs b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnValidator.cs new file mode 100644 index 0000000..2ea5b42 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/CreatePurchaseReturnValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class CreatePurchaseReturnValidator : AbstractValidator +{ + public CreatePurchaseReturnValidator() + { + RuleFor(x => x.ReturnDate).NotEmpty().WithMessage("Return Date is required"); + RuleFor(x => x.GoodsReceiveId).NotEmpty().WithMessage("Goods Receive reference is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnByIdHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnByIdHandler.cs new file mode 100644 index 0000000..93d8d98 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnByIdHandler.cs @@ -0,0 +1,31 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public record DeletePurchaseReturnByIdCommand(string Id) : IRequest; + +public class DeletePurchaseReturnByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseReturnByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseReturnByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseReturn + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.PurchaseReturn)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(transactions); + _context.PurchaseReturn.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnItemHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnItemHandler.cs new file mode 100644 index 0000000..d67a0ba --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/DeletePurchaseReturnItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public record DeletePurchaseReturnItemCommand(string Id) : IRequest; + +public class DeletePurchaseReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeletePurchaseReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePurchaseReturnItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnByIdHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnByIdHandler.cs new file mode 100644 index 0000000..ea3d23e --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class PurchaseReturnInvenTransResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetPurchaseReturnByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public Data.Enums.PurchaseReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? GoodsReceiveId { get; set; } + public string? GoodsReceiveNumber { 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 GetPurchaseReturnByIdQuery(string Id) : IRequest; + +public class GetPurchaseReturnByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseReturnByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseReturnByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.PurchaseReturn + .AsNoTracking() + .Include(x => x.GoodsReceive) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var details = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.PurchaseReturn)) + .Select(x => new PurchaseReturnInvenTransResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }) + .ToListAsync(cancellationToken); + + return new GetPurchaseReturnByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + ReturnDate = master.ReturnDate, + Status = master.Status, + Description = master.Description, + GoodsReceiveId = master.GoodsReceiveId, + GoodsReceiveNumber = master.GoodsReceive?.AutoNumber, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = details + }; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnListHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnListHandler.cs new file mode 100644 index 0000000..14a041e --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnListHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class GetPurchaseReturnListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public string? GoodsReceiveNumber { get; set; } + public Data.Enums.PurchaseReturnStatus Status { get; set; } +} + +public record GetPurchaseReturnListQuery() : IRequest>; + +public class GetPurchaseReturnListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetPurchaseReturnListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPurchaseReturnListQuery request, CancellationToken cancellationToken) + { + return await _context.PurchaseReturn + .AsNoTracking() + .Include(x => x.GoodsReceive) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPurchaseReturnListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + ReturnDate = x.ReturnDate, + GoodsReceiveNumber = x.GoodsReceive!.AutoNumber, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnLookupHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnLookupHandler.cs new file mode 100644 index 0000000..eb8df6c --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/GetPurchaseReturnLookupHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class PurchaseReturnLookupResponse +{ + public List GoodsReceives { get; set; } = new(); + public List Warehouses { 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 GetPurchaseReturnLookupQuery() : IRequest; + +public class GetPurchaseReturnLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetPurchaseReturnLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPurchaseReturnLookupQuery request, CancellationToken cancellationToken) + { + var response = new PurchaseReturnLookupResponse(); + + response.GoodsReceives = await _context.GoodsReceive.AsNoTracking() + .Where(x => x.Status >= GoodsReceiveStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .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(PurchaseReturnStatus)) + .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/PurchaseReturn/Cqrs/UpdatePurchaseReturnHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnHandler.cs new file mode 100644 index 0000000..1423d52 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class UpdatePurchaseReturnRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public Data.Enums.PurchaseReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? GoodsReceiveId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdatePurchaseReturnCommand(UpdatePurchaseReturnRequest Data) : IRequest; + +public class UpdatePurchaseReturnHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseReturnHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseReturnCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PurchaseReturn + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ReturnDate = request.Data.ReturnDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.GoodsReceiveId = request.Data.GoodsReceiveId; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.PurchaseReturn)) + .ToListAsync(cancellationToken); + + foreach (var trans in transactions) + { + trans.MovementDate = entity.ReturnDate ?? DateTime.Now; + trans.ModuleNumber = entity.AutoNumber; + trans.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + _context.CalculateInvenTrans(trans); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnItemHandler.cs b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnItemHandler.cs new file mode 100644 index 0000000..8b9b73d --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class UpdatePurchaseReturnItemRequest +{ + public string? Id { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdatePurchaseReturnItemCommand(UpdatePurchaseReturnItemRequest Data) : IRequest; + +public class UpdatePurchaseReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdatePurchaseReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePurchaseReturnItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.WarehouseId = request.Data.WarehouseId; + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnValidator.cs b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnValidator.cs new file mode 100644 index 0000000..7583a1e --- /dev/null +++ b/Features/Purchase/PurchaseReturn/Cqrs/UpdatePurchaseReturnValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +public class UpdatePurchaseReturnValidator : AbstractValidator +{ + public UpdatePurchaseReturnValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.GoodsReceiveId).NotEmpty().WithMessage("Goods Receive reference is required"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/PurchaseReturnEndpoint.cs b/Features/Purchase/PurchaseReturn/PurchaseReturnEndpoint.cs new file mode 100644 index 0000000..9cb7641 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/PurchaseReturnEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PurchaseReturn.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PurchaseReturn; + +public static class PurchaseReturnEndpoint +{ + public static void MapPurchaseReturnEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/purchase-return").WithTags("PurchaseReturns") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReturnListQuery()); + return result.ToApiResponse("Purchase return list retrieved successfully"); + }) + .WithName("GetPurchaseReturnList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReturnByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Purchase return detail retrieved successfully" + : $"Purchase return with ID {id} not found"); + }) + .WithName("GetPurchaseReturnById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReturnLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetPurchaseReturnLookup"); + + group.MapPost("/", async (CreatePurchaseReturnRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseReturnCommand(request)); + return result.ToApiResponse("Purchase return has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePurchaseReturn"); + + group.MapPost("/update", async (UpdatePurchaseReturnRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseReturnCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Purchase return not found."); + } + return result.ToApiResponse("Purchase return has been updated successfully"); + }) + .WithName("UpdatePurchaseReturn"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseReturnByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Purchase return not found."); + } + return true.ToApiResponse("Purchase return has been deleted successfully"); + }) + .WithName("DeletePurchaseReturnById"); + + group.MapPost("/purchase-return-item", async (CreatePurchaseReturnItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePurchaseReturnItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreatePurchaseReturnItem"); + + group.MapPost("/purchase-return-item/update", async (UpdatePurchaseReturnItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePurchaseReturnItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdatePurchaseReturnItem"); + + group.MapPost("/purchase-return-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePurchaseReturnItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeletePurchaseReturnItem"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturn/PurchaseReturnPdfGenerator.cs b/Features/Purchase/PurchaseReturn/PurchaseReturnPdfGenerator.cs new file mode 100644 index 0000000..82beffe --- /dev/null +++ b/Features/Purchase/PurchaseReturn/PurchaseReturnPdfGenerator.cs @@ -0,0 +1,110 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Purchase.PurchaseReturn.Cqrs; + +namespace Indotalent.Features.Purchase.PurchaseReturn; + +public class PurchaseReturnPdfGenerator +{ + public static byte[] Generate(GetPurchaseReturnByIdResponse data) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryRed = "#B71C1C"; + 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 RETURN") + .FontSize(20).ExtraBold().FontColor(primaryRed); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("RETURN SLIP").FontSize(11).ExtraBold(); + col.Item().Text($"GR Reference: {data.GoodsReceiveNumber}").FontColor(textSlate); + col.Item().Text($"Date: {data.ReturnDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().PaddingTop(10).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Name"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Return Qty"); + + static IContainer HeaderStyle(IContainer container) + { + return container.DefaultTextStyle(x => x.SemiBold().FontColor(Colors.White)) + .PaddingVertical(5).PaddingHorizontal(5).Background("#B71C1C"); + } + }); + + var index = 1; + foreach (var item in data.Items) + { + table.Cell().Element(CellStyle).Text($"{index++}"); + table.Cell().Element(CellStyle).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(20).Row(row => + { + row.RelativeItem().Column(c => { + c.Item().Text("Description:").FontSize(8).Bold(); + c.Item().Text(string.IsNullOrEmpty(data.Description) ? "-" : data.Description).FontSize(8); + c.Item().PaddingTop(10).Text($"Status: {data.Status.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryRed); + }); + + row.ConstantItem(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse / Manager )").FontSize(7); + }); + }); + }); + + 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/PurchaseReturn/PurchaseReturnService.cs b/Features/Purchase/PurchaseReturn/PurchaseReturnService.cs new file mode 100644 index 0000000..f859ff4 --- /dev/null +++ b/Features/Purchase/PurchaseReturn/PurchaseReturnService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PurchaseReturn.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PurchaseReturn; + +public class PurchaseReturnService : BaseService +{ + private readonly RestClient _client; + + public PurchaseReturnService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPurchaseReturnListAsync() + { + var request = new RestRequest("api/purchase-return", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPurchaseReturnByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-return/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetPurchaseReturnLookupAsync() + { + var request = new RestRequest("api/purchase-return/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePurchaseReturnAsync(CreatePurchaseReturnRequest data) + { + var request = new RestRequest("api/purchase-return", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseReturnAsync(UpdatePurchaseReturnRequest data) + { + var request = new RestRequest("api/purchase-return/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseReturnByIdAsync(string id) + { + var request = new RestRequest($"api/purchase-return/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreatePurchaseReturnItemAsync(CreatePurchaseReturnItemRequest data) + { + var request = new RestRequest("api/purchase-return/purchase-return-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePurchaseReturnItemAsync(UpdatePurchaseReturnItemRequest data) + { + var request = new RestRequest("api/purchase-return/purchase-return-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePurchaseReturnItemAsync(string id) + { + var request = new RestRequest($"api/purchase-return/purchase-return-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/PurchaseReturnReport/Components/PurchaseReturnReportPage.razor b/Features/Purchase/PurchaseReturnReport/Components/PurchaseReturnReportPage.razor new file mode 100644 index 0000000..fe50002 --- /dev/null +++ b/Features/Purchase/PurchaseReturnReport/Components/PurchaseReturnReportPage.razor @@ -0,0 +1,8 @@ +@page "/purchase/purchase-return-report" +@using Indotalent.Features.Purchase.PurchaseReturnReport.Components +@using MudBlazor + +<_PurchaseReturnReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturnReport/Components/_PurchaseReturnReportDataTable.razor b/Features/Purchase/PurchaseReturnReport/Components/_PurchaseReturnReportDataTable.razor new file mode 100644 index 0000000..cf6f9db --- /dev/null +++ b/Features/Purchase/PurchaseReturnReport/Components/_PurchaseReturnReportDataTable.razor @@ -0,0 +1,301 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.PurchaseReturnReport +@using Indotalent.Features.Purchase.PurchaseReturnReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PurchaseReturnReportService PurchaseReturnReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Purchase Return Report + Comprehensive report for all vendor returns and stock adjustments. +
+
+ + / + Purchase + / + Purchase Return Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Return No + + + Goods Receive + + + Vendor + + + Warehouse + + + Product Number + + + Product Name + + + Qty + + + Return Date + + + + @context.PurchaseReturnNumber + @context.GoodsReceiveNumber + @context.VendorName + @context.WarehouseName + @context.ProductNumber + @context.ProductName + @context.Quantity?.ToString("N2") + @context.ReturnDate?.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 PurchaseReturnReportService.GetPurchaseReturnReportListAsync(); + 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.PurchaseReturnNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.GoodsReceiveNumber?.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("PurchaseReturnReports"); + var currentRow = 1; + + string[] headers = { "Return No", "Goods Receive", "Vendor", "Warehouse", "Product Number", "Product Name", "Qty", "Return 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.PurchaseReturnNumber; + worksheet.Cell(currentRow, 2).Value = item.GoodsReceiveNumber; + worksheet.Cell(currentRow, 3).Value = item.VendorName; + worksheet.Cell(currentRow, 4).Value = item.WarehouseName; + worksheet.Cell(currentRow, 5).Value = item.ProductNumber; + worksheet.Cell(currentRow, 6).Value = item.ProductName; + worksheet.Cell(currentRow, 7).Value = item.Quantity; + worksheet.Cell(currentRow, 8).Value = item.ReturnDate?.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_Return_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/PurchaseReturnReport/Cqrs/GetPurchaseReturnReportListHandler.cs b/Features/Purchase/PurchaseReturnReport/Cqrs/GetPurchaseReturnReportListHandler.cs new file mode 100644 index 0000000..344e04c --- /dev/null +++ b/Features/Purchase/PurchaseReturnReport/Cqrs/GetPurchaseReturnReportListHandler.cs @@ -0,0 +1,66 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.PurchaseReturnReport.Cqrs; + +public record GetPurchaseReturnReportListDto +{ + public string? Id { get; init; } + public string? PurchaseReturnNumber { get; init; } + public string? GoodsReceiveNumber { get; init; } + public string? VendorName { get; init; } + public string? WarehouseName { get; init; } + public string? ProductNumber { get; init; } + public string? ProductName { get; init; } + public double? Quantity { get; init; } + public DateTime? ReturnDate { get; init; } +} + +public record GetPurchaseReturnReportListQuery() : IRequest>; + +public class GetPurchaseReturnReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPurchaseReturnReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetPurchaseReturnReportListQuery request, CancellationToken cancellationToken) + { + var query = _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleName == "PurchaseReturn") + .GroupJoin( + _context.PurchaseReturn.AsNoTracking() + .Include(x => x.GoodsReceive) + .ThenInclude(x => x!.PurchaseOrder) + .ThenInclude(x => x!.Vendor), + inventory => inventory.ModuleId, + purchaseReturn => purchaseReturn.Id, + (inventory, returns) => new { inventory, returns } + ) + .SelectMany( + x => x.returns.DefaultIfEmpty(), + (x, purchaseReturn) => new GetPurchaseReturnReportListDto + { + Id = x.inventory.Id, + PurchaseReturnNumber = purchaseReturn != null ? purchaseReturn.AutoNumber : x.inventory.ModuleNumber, + GoodsReceiveNumber = (purchaseReturn != null && purchaseReturn.GoodsReceive != null) ? purchaseReturn.GoodsReceive.AutoNumber : string.Empty, + VendorName = (purchaseReturn != null && purchaseReturn.GoodsReceive != null && purchaseReturn.GoodsReceive.PurchaseOrder != null && purchaseReturn.GoodsReceive.PurchaseOrder.Vendor != null) ? purchaseReturn.GoodsReceive.PurchaseOrder.Vendor.Name : string.Empty, + WarehouseName = x.inventory.Warehouse != null ? x.inventory.Warehouse.Name : string.Empty, + ProductNumber = x.inventory.Product != null ? x.inventory.Product.AutoNumber : string.Empty, + ProductName = x.inventory.Product != null ? x.inventory.Product.Name : string.Empty, + Quantity = x.inventory.Movement, + ReturnDate = purchaseReturn != null ? purchaseReturn.ReturnDate : x.inventory.MovementDate + } + ) + .OrderByDescending(x => x.ReturnDate); + + return await query.ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportEndpoint.cs b/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportEndpoint.cs new file mode 100644 index 0000000..dc85871 --- /dev/null +++ b/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.PurchaseReturnReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.PurchaseReturnReport; + +public static class PurchaseReturnReportEndpoint +{ + public static void MapPurchaseReturnReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/purchase-return-report").WithTags("PurchaseReturnReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPurchaseReturnReportListQuery()); + return result.ToApiResponse("Purchase return report list retrieved successfully"); + }) + .WithName("GetPurchaseReturnReportList"); + } +} \ No newline at end of file diff --git a/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportService.cs b/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportService.cs new file mode 100644 index 0000000..eb18639 --- /dev/null +++ b/Features/Purchase/PurchaseReturnReport/PurchaseReturnReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.PurchaseReturnReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.PurchaseReturnReport; + +public class PurchaseReturnReportService : BaseService +{ + private readonly RestClient _client; + + public PurchaseReturnReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPurchaseReturnReportListAsync() + { + var request = new RestRequest("api/purchase-return-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Purchase/ReceiveReport/Components/ReceiveReportPage.razor b/Features/Purchase/ReceiveReport/Components/ReceiveReportPage.razor new file mode 100644 index 0000000..6f8ad45 --- /dev/null +++ b/Features/Purchase/ReceiveReport/Components/ReceiveReportPage.razor @@ -0,0 +1,8 @@ +@page "/purchase/receive-report" +@using Indotalent.Features.Purchase.ReceiveReport.Components +@using MudBlazor + +<_ReceiveReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Purchase/ReceiveReport/Components/_ReceiveReportDataTable.razor b/Features/Purchase/ReceiveReport/Components/_ReceiveReportDataTable.razor new file mode 100644 index 0000000..6cb9af0 --- /dev/null +++ b/Features/Purchase/ReceiveReport/Components/_ReceiveReportDataTable.razor @@ -0,0 +1,301 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Purchase.ReceiveReport +@using Indotalent.Features.Purchase.ReceiveReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject ReceiveReportService ReceiveReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Goods Receive Report + Detailed log of incoming stock from vendors and purchase order fulfillment. +
+
+ + / + Purchase + / + Receive Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Receive No + + + Purchase Order + + + Vendor + + + Warehouse + + + Product Number + + + Product Name + + + Qty + + + Receive Date + + + + @context.GoodsReceiveNumber + @context.PurchaseOrderNumber + @context.VendorName + @context.WarehouseName + @context.ProductNumber + @context.ProductName + @context.Quantity?.ToString("N2") + @context.ReceiveDate?.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 ReceiveReportService.GetReceiveReportListAsync(); + 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.GoodsReceiveNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.VendorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.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("ReceiveReports"); + var currentRow = 1; + + string[] headers = { "Receive No", "Purchase Order", "Vendor", "Warehouse", "Product Number", "Product Name", "Quantity", "Receive 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.GoodsReceiveNumber; + worksheet.Cell(currentRow, 2).Value = item.PurchaseOrderNumber; + worksheet.Cell(currentRow, 3).Value = item.VendorName; + worksheet.Cell(currentRow, 4).Value = item.WarehouseName; + worksheet.Cell(currentRow, 5).Value = item.ProductNumber; + worksheet.Cell(currentRow, 6).Value = item.ProductName; + worksheet.Cell(currentRow, 7).Value = item.Quantity; + worksheet.Cell(currentRow, 8).Value = item.ReceiveDate?.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", "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/Purchase/ReceiveReport/Cqrs/GetReceiveReportListHandler.cs b/Features/Purchase/ReceiveReport/Cqrs/GetReceiveReportListHandler.cs new file mode 100644 index 0000000..8586ed0 --- /dev/null +++ b/Features/Purchase/ReceiveReport/Cqrs/GetReceiveReportListHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Purchase.ReceiveReport.Cqrs; + +public record GetReceiveReportListDto +{ + public string? Id { get; init; } + public string? GoodsReceiveNumber { get; init; } + public string? PurchaseOrderNumber { get; init; } + public string? VendorName { get; init; } + public string? WarehouseName { get; init; } + public string? ProductNumber { get; init; } + public string? ProductName { get; init; } + public double? Quantity { get; init; } + public DateTime? ReceiveDate { get; init; } +} + +public record GetReceiveReportListQuery() : IRequest>; + +public class GetReceiveReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetReceiveReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetReceiveReportListQuery request, CancellationToken cancellationToken) + { + var query = _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleName == "GoodsReceive") + .GroupJoin( + _context.GoodsReceive.AsNoTracking() + .Include(x => x.PurchaseOrder) + .ThenInclude(x => x!.Vendor), + inventory => inventory.ModuleId, + receive => receive.Id, + (inventory, receives) => new { inventory, receives } + ) + .SelectMany( + x => x.receives.DefaultIfEmpty(), + (x, goodsReceive) => new GetReceiveReportListDto + { + Id = x.inventory.Id, + GoodsReceiveNumber = goodsReceive != null ? goodsReceive.AutoNumber : x.inventory.ModuleNumber, + PurchaseOrderNumber = (goodsReceive != null && goodsReceive.PurchaseOrder != null) ? goodsReceive.PurchaseOrder.AutoNumber : string.Empty, + VendorName = (goodsReceive != null && goodsReceive.PurchaseOrder != null && goodsReceive.PurchaseOrder.Vendor != null) ? goodsReceive.PurchaseOrder.Vendor.Name : string.Empty, + WarehouseName = x.inventory.Warehouse != null ? x.inventory.Warehouse.Name : string.Empty, + ProductNumber = x.inventory.Product != null ? x.inventory.Product.AutoNumber : string.Empty, + ProductName = x.inventory.Product != null ? x.inventory.Product.Name : string.Empty, + Quantity = x.inventory.Movement, + ReceiveDate = goodsReceive != null ? goodsReceive.ReceiveDate : x.inventory.MovementDate + } + ) + .OrderByDescending(x => x.ReceiveDate); + + return await query.ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Purchase/ReceiveReport/ReceiveReportEndpoint.cs b/Features/Purchase/ReceiveReport/ReceiveReportEndpoint.cs new file mode 100644 index 0000000..d4838e2 --- /dev/null +++ b/Features/Purchase/ReceiveReport/ReceiveReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Purchase.ReceiveReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Purchase.ReceiveReport; + +public static class ReceiveReportEndpoint +{ + public static void MapReceiveReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/receive-report").WithTags("ReceiveReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetReceiveReportListQuery()); + return result.ToApiResponse("Receive report list retrieved successfully"); + }) + .WithName("GetReceiveReportList"); + } +} \ No newline at end of file diff --git a/Features/Purchase/ReceiveReport/ReceiveReportService.cs b/Features/Purchase/ReceiveReport/ReceiveReportService.cs new file mode 100644 index 0000000..8602ae2 --- /dev/null +++ b/Features/Purchase/ReceiveReport/ReceiveReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Purchase.ReceiveReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Purchase.ReceiveReport; + +public class ReceiveReportService : BaseService +{ + private readonly RestClient _client; + + public ReceiveReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetReceiveReportListAsync() + { + var request = new RestRequest("api/receive-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..da1615f --- /dev/null +++ b/Features/Root/App.razor @@ -0,0 +1,43 @@ +@using Indotalent.Features.Root +@using System.Security.Claims + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..53cb637 --- /dev/null +++ b/Features/Root/Home/Components/DashboardPage.razor @@ -0,0 +1,661 @@ +@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 + +CRM Dashboard + +@if (_isLoading) +{ +
+
+
+

Loading dashboard data...

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

CRM Dashboard

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

CRM Overview

+

Key Metrics

+
+
+ + + +
+
+
+
+

Monthly Revenue

+

@_data.FormattedMonthlySales

+
+
+

Pipeline Value

+

@_data.FormattedPipeline

+
+
+

Win Rate

+

@_data.AvgWinRate%

+
+
+

Total Leads

+

@_data.FormattedTotalLeads

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

Monthly Revenue

@_data.FormattedMonthlySales

+
+
+
+
+
+

Pipeline Value

@_data.FormattedPipeline

+
+
+
+
+
+

Win Rate

@_data.AvgWinRate%

+
+
+
+
+
+

Total Leads

@_data.FormattedTotalLeads

+
+
+
+
+
+
+
+

Avg Deal Size

@_data.FormattedAvgDealSize

+
+
+
+
+
+

CVR

@_data.OverallCvr%

+
+
+
+
+
+

Active Deals

@_data.ActiveDealsCount

+
+
+
+
+
+

AR Balance

@_data.FormattedPendingPayments

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

CRM Metrics Matrix

Key indicators across all categories

+
+
+ @{ + var mmItems = new[] { + new { Title = "Monthly Revenue", Sub = _data.FormattedMonthlySales, ColorClass = "mm-c1" }, + new { Title = "Pipeline Value", Sub = _data.FormattedPipeline, ColorClass = "mm-c2" }, + new { Title = "Win Rate", Sub = $"{_data.AvgWinRate}%", ColorClass = "mm-c3" }, + new { Title = "Total Leads", Sub = _data.FormattedTotalLeads, ColorClass = "mm-c4" }, + new { Title = "Avg Deal Size", Sub = _data.FormattedAvgDealSize, ColorClass = "mm-c5" }, + new { Title = "CVR", Sub = $"{_data.OverallCvr}%", ColorClass = "mm-c6" }, + new { Title = "Active Deals", Sub = _data.ActiveDealsCount.ToString(), ColorClass = "mm-c7" }, + new { Title = "AR Balance", Sub = _data.FormattedPendingPayments, ColorClass = "mm-c8" }, + new { Title = "Contacts", Sub = _data.TotalContacts.ToString("N0"), ColorClass = "mm-c9" }, + new { Title = "Campaigns", Sub = _data.TotalCampaigns.ToString(), ColorClass = "mm-c10" }, + new { Title = "Conversion", Sub = $"{_data.OverallCvr}%", ColorClass = "mm-c11" }, + new { Title = "Retention", Sub = $"{_data.RetentionRate}%", ColorClass = "mm-c12" }, + new { Title = "Won Deals", Sub = _data.WonDealsCount.ToString(), ColorClass = "mm-c13" }, + new { Title = "Budget", Sub = _data.FormattedBudget, ColorClass = "mm-c14" }, + new { Title = "Expense", Sub = _data.FormattedExpense, ColorClass = "mm-c15" }, + new { Title = "Pipeline Total", Sub = _data.FormattedPipeline, ColorClass = "mm-c16" }, + new { Title = "SLA", Sub = $"{_data.SlaPercentage}%", ColorClass = "mm-c17" }, + new { Title = "NPS", Sub = $"{_data.RetentionRate}", ColorClass = "mm-c18" }, + new { Title = "Campaigns", Sub = _data.ActiveCampaigns.ToString(), ColorClass = "mm-c19" }, + new { Title = "Enterprise", Sub = $"{_data.EnterprisePercentage}%", ColorClass = "mm-c20" } + }; + } + @foreach (var item in mmItems) + { +
+
+ +
+

@item.Title

@item.Sub

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

Revenue Trend

Monthly revenue vs expense

+
+ Revenue + Expense +
+
+
+
+
+

Lead Sources

+

By category this period

+
+
+ @if (_data.LeadSources.Any()) + { + @foreach (var src in _data.LeadSources) + { +
+ @src.Source @src.Percentage% +
+ } + } + else + { +
No lead source data available
+ } +
+
+
+ + @* ROW 3: Pipeline Summary + Deal Stages + Sales Activity *@ +
+
+

Pipeline Summary

+

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

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

@ps.Label

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

+
+ } +
+ Win Rate@_data.AvgWinRate% + Deals@_data.WonDealsCount +
+
+
+
+

Deal Stages

+

Pipeline by stage

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

@ds.Stage

@ds.Count

+
+ } +
+
CVR: @_data.OverallCvr%Active: @_data.ActiveDealsCount
+
+
+

Sales Activity

+

Monthly movement

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

@sa.Category

+ } +
+
+
+ + @* ROW 4: Recent Deals + CRM Activity *@ +
+
+
+

Recent Deals

Latest opportunities

+
+
+ + + + @if (_data.RecentDeals.Any()) + { + @foreach (var deal in _data.RecentDeals) + { + + + + + + + + } + } + else + { + + } + +
DateDeal#DescriptionValueStatus
@(deal.Date?.ToString("MMM dd") ?? "-")@deal.DealNumber@deal.Description@deal.Value.ToString("N0")@deal.Status
No recent deals available
+
+
+ Active Deals: @_data.ActiveDealsCount + Total Pipeline: @_data.FormattedPipeline +
+
+
+
+

CRM 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: Conversion Metrics + Top Performers + Support Metrics *@ +
+
+

Conversion Metrics

+

@DateTime.Now.Year Performance

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

@cm.Label

+ @cm.Current / @cm.Target +
+
+
+
+
+ } +
+
+ Target CVR: @_data.TargetCvr% + @(_data.OverallCvr >= 5 ? "On Track" : "Improving") +
+
+
+

Top Performers

+

This quarter ranking

+
+ @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 Deals: @_data.WonDealsCountAvg Win: @_data.AvgWinRate%
+
+
+

Support Metrics

+

Customer service KPIs

+
+
+
+

Active Deals

Total in progress

+
@_data.ActiveDealsCount
+
+
+
+

Total Contacts

Active contacts

+
@_data.TotalContacts.ToString("N0")
+
+
+
+

Retention Rate

Customer loyalty

+
@_data.RetentionRate%
+
+
+
+

Budget

Total allocated

+
@_data.FormattedBudget
+
+
+
+
+ + @* ROW 6: Lead Overview + Upcoming Renewals *@ +
+
+
+

Lead Overview

As of @DateTime.Now.ToString("MMM dd, yyyy") · Recent leads

+
+
+
+ + + + @if (_data.RecentLeads.Any()) + { + @foreach (var lead in _data.RecentLeads) + { + + + + + + + } + } + else + { + + } + +
LeadCompanyAmountStage
@lead.LeadTitle@lead.Company@lead.Amount.ToString("N0")@lead.Stage
No leads available
+
+
+
+ Total Leads: @_data.FormattedTotalLeads + Avg CVR: @_data.OverallCvr% +
+
+
+
+

Overdue Invoices

Pending payments

+
+
+
+ + + + @if (_data.OverdueInvoices.Any()) + { + @foreach (var inv in _data.OverdueInvoices) + { + + + + + + + } + } + else + { + + } + +
InvoiceCustomerAmountStatus
@inv.Number@inv.Customer@inv.Amount.ToString("N0") + + @inv.Status + +
No overdue invoices
+
+
+
+ Total AR: @_data.FormattedPendingPayments + Count: @_data.OverdueInvoices.Count +
+
+
+ +
+
+} + +@code { + private DashboardCrmResponse _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.GetCrmDashboardStatsAsync(); + 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 revenueLabels = _data.RevenueTrend.Select(x => x.Label).ToArray(); + var revenueData = _data.RevenueTrend.Select(x => x.SalesAmount).ToArray(); + var expenseData = _data.RevenueTrend.Select(x => x.PurchaseAmount).ToArray(); + + var sourceLabels = _data.LeadSources.Select(x => x.Source).ToArray(); + var sourceData = _data.LeadSources.Select(x => x.Percentage).Cast().ToArray(); + var sourceColors = _data.LeadSources.Select(x => x.Color).ToArray(); + + var activityLabels = _data.SalesActivities.Take(4).Select(x => x.Category).ToArray(); + var activityCounts = _data.SalesActivities.Take(4).Select(x => (double)x.Count).ToArray(); + var activityColors = _data.SalesActivities.Take(4).Select(x => x.Color).ToArray(); + + await JS.InvokeVoidAsync("initDashboardCharts", + revenueLabels, revenueData, expenseData, + sourceLabels, sourceData, sourceColors, + activityLabels, activityCounts, activityColors); + } +} + + + +@* 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..f96ecca --- /dev/null +++ b/Features/Root/Home/Cqrs/DashboardHandler.cs @@ -0,0 +1,411 @@ +using Indotalent.ConfigBackEnd.Extensions; +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 SalesPipelineDto { public string? Stage { get; set; } public int Count { get; set; } public decimal Value { get; set; } } +public class LeadActivityDto { public string? Summary { get; set; } public string? LeadName { get; set; } public string? Type { get; set; } public DateTime? Date { get; set; } } +public class CampaignRoiDto { public string? Label { get; set; } public decimal Cost { get; set; } public decimal Revenue { get; set; } public double Roi { 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 BantScoreDto { public string? Label { get; set; } public string? Value { get; set; } public string? Color { get; set; } } +public class UpcomingFollowUpDto { public string? LeadName { get; set; } public string? Summary { get; set; } public DateTime? ScheduledDate { get; set; } public string? Type { get; set; } } + +// New DTOs for dashboard3 reference layout +public class DealStageDto { public string? Stage { get; set; } public int Count { get; set; } public string? Color { get; set; } } +public class LeadSourceDto { public string? Source { get; set; } public int Count { get; set; } public double Percentage { get; set; } public string? Color { get; set; } } +public class ConversionMetricDto { 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 SalesActivityDto { public string? Category { get; set; } public int Count { get; set; } public string? Color { get; set; } } +public class RecentDealDto { public string? DealNumber { get; set; } public string? Description { get; set; } public decimal Value { get; set; } public string? Stage { get; set; } public string? Status { get; set; } public DateTimeOffset? 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 DashboardCrmResponse +{ + public int TotalLeads { get; set; } + public decimal TotalPipelineValue { get; set; } + public decimal MonthlySales { get; set; } + public double AvgWinRate { get; set; } + public int ActiveCampaigns { get; set; } + public int WonDealsCount { get; set; } + public decimal PendingPayments { get; set; } + public decimal TotalBudget { get; set; } + public decimal ActualExpense { get; set; } + public double RetentionRate { get; set; } + public decimal AvgDealSize { get; set; } + public double OverallCvr { get; set; } + public int ActiveDealsCount { get; set; } + public int TotalContacts { get; set; } + public int TotalCampaigns { get; set; } + public double SlaPercentage { get; set; } + public double EnterprisePercentage { get; set; } + public double TargetCvr { get; set; } + public List PipelineBreakdown { get; set; } = new(); + public List RecentActivities { get; set; } = new(); + public List TopCampaigns { get; set; } = new(); + public List SalesLeaderboard { get; set; } = new(); + public List OverdueInvoices { get; set; } = new(); + public List RecentLeads { get; set; } = new(); + public List BantMetrics { get; set; } = new(); + public List UpcomingFollowUps { get; set; } = new(); + public List MonthlySalesVsPurchase { get; set; } = new(); + public List WeeklySalesVsPurchase { get; set; } = new(); + + // New properties for dashboard3 layout + public List DealStages { get; set; } = new(); + public List LeadSources { get; set; } = new(); + public List ConversionMetrics { get; set; } = new(); + public List SalesActivities { get; set; } = new(); + public List RecentDeals { get; set; } = new(); + public List ActivityFeeds { get; set; } = new(); + public List PipelineSummary { get; set; } = new(); + public List RevenueTrend { get; set; } = new(); + + public string FormattedPipeline => FormatNumber(TotalPipelineValue); + public string FormattedMonthlySales => FormatNumber(MonthlySales); + public string FormattedPendingPayments => FormatNumber(PendingPayments); + public string FormattedBudget => FormatNumber(TotalBudget); + public string FormattedExpense => FormatNumber(ActualExpense); + public string FormattedAvgDealSize => FormatNumber(AvgDealSize); + public string FormattedTotalLeads => TotalLeads.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 GetCrmDashboardQuery() : IRequest; + +public class DashboardHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DashboardHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCrmDashboardQuery request, CancellationToken ct) + { + var today = DateTime.Today; + var monthStart = new DateTime(today.Year, today.Month, 1); + + var pipeline = await _context.Lead.NotDeletedOnly() + .GroupBy(x => x.PipelineStage) + .Select(g => new SalesPipelineDto + { + Stage = g.Key.ToString(), + Count = g.Count(), + Value = g.Sum(x => x.AmountTargeted ?? 0) + }).ToListAsync(ct); + + var leaders = await _context.SalesRepresentative.NotDeletedOnly() + .Take(5) + .Select(x => new SalesPerformanceDto + { + Name = x.Name, + Achievement = _context.Lead + .Where(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon) + .Sum(l => l.AmountClosed ?? 0), + Deals = _context.Lead + .Count(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon), + WinRate = _context.Lead.Where(l => l.SalesTeamId == x.SalesTeamId).Any() + ? (double)_context.Lead.Count(l => l.SalesTeamId == x.SalesTeamId && l.ClosingStatus == ClosingStatus.ClosedWon) + / _context.Lead.Count(l => l.SalesTeamId == x.SalesTeamId) * 100 + : 0 + }).OrderByDescending(x => x.Achievement).ToListAsync(ct); + + var campaigns = await _context.Campaign.NotDeletedOnly() + .Take(3) + .Select(x => new CampaignRoiDto + { + Label = x.Title, + Cost = x.ExpenseList.Sum(e => e.Amount ?? 0), + Revenue = x.LeadList.Where(l => l.ClosingStatus == ClosingStatus.ClosedWon).Sum(l => l.AmountClosed ?? 0), + Roi = x.ExpenseList.Sum(e => e.Amount ?? 0) > 0 + ? (double)(x.LeadList.Where(l => l.ClosingStatus == ClosingStatus.ClosedWon).Sum(l => l.AmountClosed ?? 0) / x.ExpenseList.Sum(e => e.Amount ?? 0)) + : 0 + }).ToListAsync(ct); + + var overdue = await _context.Invoice.NotDeletedOnly() + .Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid) + .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); + + var recentLeads = await _context.Lead.NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt).Take(5) + .Select(x => new RecentLeadDto + { + LeadTitle = x.Title, + Company = x.CompanyName, + Amount = x.AmountTargeted ?? 0, + Stage = x.PipelineStage.ToString() + }).ToListAsync(ct); + + var recentActivities = await _context.LeadActivity.NotDeletedOnly() + .OrderByDescending(x => x.FromDate).Take(10) + .Select(x => new LeadActivityDto + { + Summary = x.Summary, + LeadName = x.Lead!.Title, + Type = x.Type.ToString(), + Date = x.FromDate + }).ToListAsync(ct); + + var followUps = await _context.LeadActivity.NotDeletedOnly() + .Where(x => x.FromDate >= today && (x.Type == LeadActivityType.Phone || x.Type == LeadActivityType.Meeting)) + .OrderBy(x => x.FromDate) + .Take(5) + .Select(x => new UpcomingFollowUpDto + { + LeadName = x.Lead!.Title, + Summary = x.Summary, + ScheduledDate = x.FromDate, + Type = x.Type.ToString() + }).ToListAsync(ct); + + var totalLeadsCount = await _context.Lead.NotDeletedOnly().CountAsync(ct); + var wonLeadsCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.ClosingStatus == ClosingStatus.ClosedWon, ct); + + var bantMetrics = new List + { + new() { Label = "Hot Deals", Value = await _context.Lead.CountAsync(x => x.BudgetScore > 80 && x.ClosingStatus == ClosingStatus.OnProgress, ct).ContinueWith(t => t.Result.ToString()), Color = "#f59e0b" }, + new() { Label = "Budget Ready", Value = await _context.Lead.CountAsync(x => x.BudgetScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#f59e0b" }, + new() { Label = "Decision Maker", Value = await _context.Lead.CountAsync(x => x.AuthorityScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#10b981" }, + new() { Label = "Timeline Ready", Value = await _context.Lead.CountAsync(x => x.TimelineScore >= 70, ct).ContinueWith(t => t.Result.ToString()), Color = "#3b82f6" }, + new() { Label = "SQL", Value = await _context.Lead.CountAsync(x => x.PipelineStage >= PipelineStage.Qualification, ct).ContinueWith(t => t.Result.ToString()), Color = "#64748b" }, + new() { Label = "MQL", Value = totalLeadsCount.ToString(), Color = "#1e293b" } + }; + + var monthlyLabels = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + var monthlyData = new List(); + + var soMonths = await _context.SalesOrder + .Where(x => x.OrderDate != null) + .Select(x => x.OrderDate!.Value) + .ToListAsync(ct); + var poMonths = await _context.PurchaseOrder + .Where(x => x.OrderDate != null) + .Select(x => x.OrderDate!.Value) + .ToListAsync(ct); + var allDates = soMonths.Concat(poMonths) + .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 monthStartDt = new DateTime(ym.Year, ym.Month, 1); + var monthEndDt = monthStartDt.AddMonths(1); + var salesAmount = await _context.SalesOrder + .Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt) + .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); + var purchaseAmount = await _context.PurchaseOrder + .Where(x => x.OrderDate >= monthStartDt && x.OrderDate < monthEndDt) + .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); + monthlyData.Add(new ChartDataPoint + { + Label = monthlyLabels[ym.Month - 1], + SalesAmount = Math.Round(salesAmount / 1000.0, 1), + PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1) + }); + } + + var weeklyData = new List(); + var soWeeks = await _context.SalesOrder + .Where(x => x.OrderDate != null) + .Select(x => x.OrderDate!.Value) + .ToListAsync(ct); + var poWeeks = await _context.PurchaseOrder + .Where(x => x.OrderDate != null) + .Select(x => x.OrderDate!.Value) + .ToListAsync(ct); + var allWeekStarts = soWeeks.Concat(poWeeks) + .Select(d => d.AddDays(-(int)d.DayOfWeek)) + .Distinct() + .OrderByDescending(w => w) + .Take(6) + .Reverse() + .ToList(); + foreach (var weekStart in allWeekStarts) + { + var weekEnd = weekStart.AddDays(7); + var salesAmount = await _context.SalesOrder + .Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd) + .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); + var purchaseAmount = await _context.PurchaseOrder + .Where(x => x.OrderDate >= weekStart && x.OrderDate < weekEnd) + .SumAsync(x => (double?)x.AfterTaxAmount ?? 0, ct); + weeklyData.Add(new ChartDataPoint + { + Label = $"Wk {weekStart:MM/dd}", + SalesAmount = Math.Round(salesAmount / 1000.0, 1), + PurchaseAmount = Math.Round(purchaseAmount / 1000.0, 1) + }); + } + + // New queries for dashboard3 layout + var totalPipeline = pipeline.Sum(x => x.Value); + var totalClosedWon = await _context.Lead.NotDeletedOnly() + .Where(x => x.ClosingStatus == ClosingStatus.ClosedWon) + .SumAsync(x => x.AmountClosed ?? 0, ct); + + var dealStages = new List + { + new() { Stage = "Prospecting", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Prospecting, ct), Color = "#8b5cf6" }, + new() { Stage = "Qualification", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Qualification, ct), Color = "#f59e0b" }, + new() { Stage = "Negotiation", Count = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Negotiation, ct), Color = "#e11d48" }, + new() { Stage = "Closed Won", Count = wonLeadsCount, Color = "#6366f1" } + }; + + var pipelineSummaryList = new List + { + new() { Label = "Total Pipeline", Value = totalPipeline, FormattedValue = totalPipeline >= 1000000 ? $"${totalPipeline / 1000000:F2}M" : totalPipeline >= 1000 ? $"${totalPipeline / 1000:F1}K" : $"${totalPipeline:N0}", IconColorClass = "gc1" }, + new() { Label = "Closed Won", Value = totalClosedWon, FormattedValue = totalClosedWon >= 1000000 ? $"${totalClosedWon / 1000000:F2}M" : totalClosedWon >= 1000 ? $"${totalClosedWon / 1000:F1}K" : $"${totalClosedWon:N0}", IconColorClass = "gc10" }, + new() { Label = "Avg Deal Size", Value = wonLeadsCount > 0 ? totalClosedWon / wonLeadsCount : 0, IconColorClass = "gc2" } + }; + + var leadSourceRaw = await _context.Lead.NotDeletedOnly() + .Select(x => x.Campaign!.Title ?? "Organic") + .ToListAsync(ct); + var leadSourceGroups = leadSourceRaw + .GroupBy(x => x) + .Select(g => new { Source = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(5) + .ToList(); + var totalSource = leadSourceGroups.Sum(x => x.Count); + var sourceColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4" }; + var leadSources = leadSourceGroups.Select((g, i) => new LeadSourceDto + { + Source = g.Source, + Count = g.Count, + Percentage = totalSource > 0 ? Math.Round((double)g.Count / totalSource * 100, 1) : 0, + Color = i < sourceColors.Length ? sourceColors[i] : "#94a3b8" + }).ToList(); + + var prospectingCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Prospecting, ct); + var qualifiedCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage == PipelineStage.Qualification, ct); + var mqlCount = totalLeadsCount; + var sqlCount = await _context.Lead.NotDeletedOnly().CountAsync(x => x.PipelineStage >= PipelineStage.Qualification, ct); + var conversionMetrics = new List + { + new() { Label = "Leads→MQL", Current = sqlCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)sqlCount / totalLeadsCount * 100, 1) : 0, BarColor = "#2563eb" }, + new() { Label = "MQL→SQL", Current = qualifiedCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)qualifiedCount / totalLeadsCount * 100, 1) : 0, BarColor = "#f97316" }, + new() { Label = "SQL→Won", Current = wonLeadsCount, Target = sqlCount > 0 ? sqlCount : 1, PctValue = sqlCount > 0 ? Math.Round((double)wonLeadsCount / sqlCount * 100, 1) : 0, BarColor = "#ec4899" }, + new() { Label = "Overall CVR", Current = wonLeadsCount, Target = totalLeadsCount, PctValue = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0, BarColor = "#10b981" } + }; + + var recentDeals = await _context.Lead.NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt).Take(4) + .Select(x => new RecentDealDto + { + DealNumber = x.AutoNumber, + Description = x.Title, + Value = x.AmountTargeted ?? 0, + Stage = x.PipelineStage.ToString(), + Status = x.ClosingStatus == ClosingStatus.ClosedWon ? "Posted" : x.ClosingStatus == ClosingStatus.ClosedLost ? "Lost" : "Pending", + Date = x.CreatedAt + }).ToListAsync(ct); + + var activityFeed = recentActivities.Take(4).Select((a, i) => new ActivityFeedDto + { + Title = a.Summary ?? a.LeadName, + Detail = $"Type: {a.Type} · Lead: {a.LeadName}", + TimeAgo = a.Date.HasValue ? (DateTime.Now - a.Date.Value).TotalMinutes < 60 ? $"{(int)(DateTime.Now - a.Date.Value).TotalMinutes} min ago" : $"{(int)(DateTime.Now - a.Date.Value).TotalHours} hour ago" : "", + ChipLabel = a.Type, + ChipColor = i % 2 == 0 ? "chip-green" : "chip-indigo", + AvatarBg = i switch { 0 => "#10b981", 1 => "#2563eb", 2 => "#6366f1", _ => "#f97316" }, + AvatarText = a.Type?.Substring(0, Math.Min(a.Type.Length, 2)).ToUpper() + }).ToList(); + + var salesActivities = new List + { + new() { Category = "Calls", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Phone, ct), Color = "#10b981" }, + new() { Category = "Emails", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Email, ct), Color = "#e11d48" }, + new() { Category = "Meetings", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(x => x.Type == LeadActivityType.Meeting, ct), Color = "#e11d48" }, + new() { Category = "Total", Count = await _context.LeadActivity.NotDeletedOnly().CountAsync(ct), Color = "#6366f1" } + }; + + var slaPercentage = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 94.2; + var enterprisePercentage = totalPipeline > 0 ? Math.Round((double)(totalClosedWon / totalPipeline) * 100, 1) : 35; + var targetCvr = 5.0; + + var revenueTrend = monthlyData; + + var avgDealSize = wonLeadsCount > 0 ? totalClosedWon / wonLeadsCount : 0; + var overallCvr = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0; + var activeDeals = await _context.Lead.NotDeletedOnly().CountAsync(x => x.ClosingStatus == ClosingStatus.OnProgress, ct); + var totalContacts = await _context.LeadContact.NotDeletedOnly().CountAsync(ct); + var totalCampaigns = await _context.Campaign.NotDeletedOnly().CountAsync(ct); + + return new DashboardCrmResponse + { + TotalLeads = totalLeadsCount, + TotalPipelineValue = totalPipeline, + MonthlySales = await _context.SalesOrder.NotDeletedOnly().Where(x => x.OrderDate >= monthStart).SumAsync(x => x.AfterTaxAmount ?? 0, ct), + AvgWinRate = totalLeadsCount > 0 ? Math.Round((double)wonLeadsCount / totalLeadsCount * 100, 1) : 0, + ActiveCampaigns = await _context.Campaign.NotDeletedOnly().CountAsync(x => x.Status == CampaignStatus.OnProgress, ct), + WonDealsCount = wonLeadsCount, + PendingPayments = await _context.Invoice.NotDeletedOnly().Where(x => x.InvoiceStatus != InvoiceStatus.FullPaid).SumAsync(x => x.SalesOrder!.AfterTaxAmount ?? 0, ct), + TotalBudget = await _context.Budget.NotDeletedOnly().SumAsync(x => x.Amount ?? 0, ct), + ActualExpense = await _context.Expense.NotDeletedOnly().SumAsync(x => x.Amount ?? 0, ct), + RetentionRate = 92.4, + SlaPercentage = slaPercentage, + EnterprisePercentage = enterprisePercentage, + TargetCvr = targetCvr, + AvgDealSize = avgDealSize, + OverallCvr = overallCvr, + ActiveDealsCount = activeDeals, + TotalContacts = totalContacts, + TotalCampaigns = totalCampaigns, + PipelineBreakdown = pipeline, + SalesLeaderboard = leaders, + TopCampaigns = campaigns, + OverdueInvoices = overdue, + RecentLeads = recentLeads, + RecentActivities = recentActivities, + BantMetrics = bantMetrics, + UpcomingFollowUps = followUps, + MonthlySalesVsPurchase = monthlyData, + WeeklySalesVsPurchase = weeklyData, + DealStages = dealStages, + LeadSources = leadSources, + ConversionMetrics = conversionMetrics, + SalesActivities = salesActivities, + RecentDeals = recentDeals, + ActivityFeeds = activityFeed, + PipelineSummary = pipelineSummaryList, + RevenueTrend = revenueTrend + }; + } +} \ 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..58875ea --- /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("/crm-stats", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCrmDashboardQuery()); + return result.ToApiResponse("CRM Dashboard data retrieved successfully"); + }) + .WithName("GetCrmDashboardStats"); + } +} \ 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..e9fc14d --- /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?> GetCrmDashboardStatsAsync() + { + var request = new RestRequest("api/dashboard/crm-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..1eed41b --- /dev/null +++ b/Features/Root/MainLayout.razor @@ -0,0 +1,258 @@ +@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 + } +
+
+ + + + @* Member & Admin *@ + + + @(_drawerOpen ? "Home" : "") + + @(_drawerOpen ? "Profile" : "") + + @(_drawerOpen ? "Pipeline" : "") + + @(_drawerOpen ? "Third Party" : "") + + @(_drawerOpen ? "Sales" : "") + + @(_drawerOpen ? "Purchase" : "") + + @(_drawerOpen ? "Inventory" : "") + + @(_drawerOpen ? "Utilities" : "") + + + + @* Admin Only *@ + + + @(_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"); +} + + + + + + + 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..bce3fcc --- /dev/null +++ b/Features/Root/Pages/LandingPage.cshtml @@ -0,0 +1,380 @@ +@page "/" + + + + + + + CRM - Blazor Enterprise CRM Source Code + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + Production-Ready Blazor CRM Source Code +
+

+ Your CRM,
+ Deployed in Minutes +

+

+ A complete, end-to-end CRM 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 CRM — with authentication, database, business logic, and a polished UI — takes countless iterations, debugging, and wasted tokens.

+
+
+
+ +
+

Guaranteed to Work

+

Every feature in this CRM 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

+

From lead to invoice, purchase to payment — 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.

+
+
+
+ +
+

Pipeline

+

Campaign management, budgeting, expense tracking, lead management with contacts & activities, sales team & representative assignments.

+
+
+
+ +
+

Third Party

+

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

+
+
+
+ +
+

Sales

+

Quotation, sales order, delivery order, sales return, invoice, credit note, and payment — with comprehensive reporting for every stage of the sales cycle.

+
+
+
+ +
+

Purchase

+

Requisition, purchase order, goods receive, purchase return, bill, debit note, and payment — complete procurement workflow with full reporting suite.

+
+
+
+ +
+

Inventory

+

Unit measure, product groups, product catalog, warehouse management, transfers, adjustments, scrapping, stock counts, and in-depth stock & movement reports.

+
+
+
+ +
+

Utilities

+

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

+
+
+
+ +
+

System Logs & Settings

+

Database and file logging, user management, currency configuration, auto numbering, and full inspection mode for appsettings.json — complete control over your application.

+
+
+
+
+ + +
+
+
+ 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 CRM 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..2a1f3d7 --- /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/CreditNote/Components/CreditNotePage.razor b/Features/Sales/CreditNote/Components/CreditNotePage.razor new file mode 100644 index 0000000..d9faff8 --- /dev/null +++ b/Features/Sales/CreditNote/Components/CreditNotePage.razor @@ -0,0 +1,51 @@ +@page "/sales/credit-note" +@using Indotalent.Features.Sales.CreditNote.Cqrs +@using Indotalent.Features.Sales.CreditNote.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CreditNoteCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CreditNoteUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_CreditNoteDataTable 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 UpdateCreditNoteRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateCreditNoteRequest 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/CreditNote/Components/_CreditNoteCreateForm.razor b/Features/Sales/CreditNote/Components/_CreditNoteCreateForm.razor new file mode 100644 index 0000000..168407b --- /dev/null +++ b/Features/Sales/CreditNote/Components/_CreditNoteCreateForm.razor @@ -0,0 +1,177 @@ +@using Indotalent.Features.Sales.CreditNote.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CreditNoteService CreditNoteService +@inject ISnackbar Snackbar + + +
+ +
+ Add Credit Note + Create a new commercial adjustment for sales return. +
+
+
+ + + + + Basic Information + + + Credit Note Date + + + + + Sales Return Reference + + @foreach (var item in _lookup.SalesReturns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_CreditNoteItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Adjustment Summary +
+ Sub Total + @_beforeTax.ToString("N2") +
+
+ Tax Amount + @_taxAmount.ToString("N2") +
+ +
+ Total Credit + @_afterTax.ToString("N2") +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Credit Note + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCreditNoteRequest _model = new() { CreditNoteDate = DateTime.Today, CreditNoteStatus = Indotalent.Data.Enums.CreditNoteStatus.Draft }; + private CreditNoteLookupResponse _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 CreditNoteService.GetCreditNoteLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + } + + private async Task OnSalesReturnChanged(string srId) + { + _model.SalesReturnId = srId; + if (string.IsNullOrEmpty(srId)) + { + _items.Clear(); + _beforeTax = 0; + _taxAmount = 0; + _afterTax = 0; + return; + } + + var res = await CreditNoteService.GetSalesReturnDetailForCreditNoteAsync(srId); + await Task.Delay(500); + if (res != null && res.IsSuccess && res.Value != null) + { + _items = res.Value.Items; + _beforeTax = res.Value.BeforeTaxAmount; + _taxAmount = res.Value.TaxAmount; + _afterTax = res.Value.AfterTaxAmount; + Snackbar.Add("Sales Return data and inventory transactions loaded.", Severity.Info); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CreditNoteService.CreateCreditNoteAsync(_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/Sales/CreditNote/Components/_CreditNoteDataTable.razor b/Features/Sales/CreditNote/Components/_CreditNoteDataTable.razor new file mode 100644 index 0000000..6859b0a --- /dev/null +++ b/Features/Sales/CreditNote/Components/_CreditNoteDataTable.razor @@ -0,0 +1,377 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.CreditNote +@using Indotalent.Features.Sales.CreditNote.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CreditNoteService CreditNoteService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Credit Note + Manage credit notes for sales returns and customer adjustments. +
+
+ + / + Sales + / + Credit Note +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Credit Note + + } +
+
+ + + + + + CN Number + + + Date + + + SR Ref + + + Customer + + + Status + + + + + + + @context.AutoNumber + @context.CreditNoteDate?.ToString("yyyy-MM-dd") + @context.SalesReturnNumber + @context.CustomerName + + @context.CreditNoteStatus.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 GetCreditNoteListResponse? _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 CreditNoteService.GetCreditNoteListAsync(); + 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.SalesReturnNumber?.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("CreditNotes"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "CN Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "SR Ref"; + worksheet.Cell(currentRow, 4).Value = "Customer"; + 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.CreditNoteDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.SalesReturnNumber; + worksheet.Cell(currentRow, 4).Value = item.CustomerName; + worksheet.Cell(currentRow, 5).Value = item.CreditNoteStatus.ToString(); + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "CreditNote_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 CreditNoteService.GetCreditNoteByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateCreditNoteRequest + { + Id = detail.Id, + CreditNoteDate = detail.CreditNoteDate, + CreditNoteStatus = detail.CreditNoteStatus, + AutoNumber = detail.AutoNumber, + Description = detail.Description, + SalesReturnId = detail.SalesReturnId, + BeforeTaxAmount = detail.BeforeTaxAmount, + TaxAmount = detail.TaxAmount, + AfterTaxAmount = detail.AfterTaxAmount, + 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 CreditNoteService.DeleteCreditNoteByIdAsync(_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/CreditNote/Components/_CreditNoteItemDataTable.razor b/Features/Sales/CreditNote/Components/_CreditNoteItemDataTable.razor new file mode 100644 index 0000000..a33cab5 --- /dev/null +++ b/Features/Sales/CreditNote/Components/_CreditNoteItemDataTable.razor @@ -0,0 +1,33 @@ +@using Indotalent.Features.Sales.CreditNote.Cqrs +@using MudBlazor + + +
+ Returned Inventory Items +
+ + + + Product + Unit Price + Qty Returned + Total Adjustment + + + @context.ProductName + @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/CreditNote/Components/_CreditNoteUpdateForm.razor b/Features/Sales/CreditNote/Components/_CreditNoteUpdateForm.razor new file mode 100644 index 0000000..8fe1d71 --- /dev/null +++ b/Features/Sales/CreditNote/Components/_CreditNoteUpdateForm.razor @@ -0,0 +1,262 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.CreditNote.Cqrs +@using Indotalent.Features.Sales.CreditNote.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject CreditNoteService CreditNoteService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Credit Note + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Credit Note Date + + + + + Sales Return Reference + + @foreach (var item in _lookup.SalesReturns) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description + + + + + <_CreditNoteItemDataTable Items="_items" ReadOnly="true" /> + + + + + + Adjustment Summary +
+ Sub Total + @_model.BeforeTaxAmount?.ToString("N2") +
+
+ Tax Amount + @_model.TaxAmount?.ToString("N2") +
+ +
+ Total Credit + @_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 UpdateCreditNoteRequest 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 UpdateCreditNoteRequest _model = new(); + private List _items = new(); + private CreditNoteLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await CreditNoteService.GetCreditNoteLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task OnSalesReturnChanged(string srId) + { + if (ReadOnly || _model.SalesReturnId == srId) return; + _model.SalesReturnId = srId; + await RefreshItems(); + Snackbar.Add("Sales Return reference changed. Items and totals recalculated.", Severity.Info); + } + + private async Task RefreshItems() + { + if (string.IsNullOrEmpty(_model.SalesReturnId)) return; + + try + { + var response = await CreditNoteService.GetSalesReturnDetailForCreditNoteAsync(_model.SalesReturnId); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items; + _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 CreditNoteService.GetCreditNoteByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = CreditNotePdfGenerator.Generate(response.Value); + var fileName = $"CN_{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 CreditNoteService.UpdateCreditNoteAsync(_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/CreditNote/Cqrs/CreateCreditNoteHandler.cs b/Features/Sales/CreditNote/Cqrs/CreateCreditNoteHandler.cs new file mode 100644 index 0000000..8f2ddce --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/CreateCreditNoteHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class CreateCreditNoteRequest +{ + public DateTime? CreditNoteDate { get; set; } + public Data.Enums.CreditNoteStatus CreditNoteStatus { get; set; } + public string? Description { get; set; } + public string? SalesReturnId { get; set; } +} + +public class CreateCreditNoteResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateCreditNoteCommand(CreateCreditNoteRequest Data) : IRequest; + +public class CreateCreditNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateCreditNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCreditNoteCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.CreditNote); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"CN/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.CreditNote + { + AutoNumber = autoNo, + CreditNoteDate = request.Data.CreditNoteDate, + CreditNoteStatus = request.Data.CreditNoteStatus, + Description = request.Data.Description, + SalesReturnId = request.Data.SalesReturnId + }; + + _context.CreditNote.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCreditNoteResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/CreateCreditNoteValidator.cs b/Features/Sales/CreditNote/Cqrs/CreateCreditNoteValidator.cs new file mode 100644 index 0000000..69ddd43 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/CreateCreditNoteValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class CreateCreditNoteValidator : AbstractValidator +{ + public CreateCreditNoteValidator() + { + RuleFor(x => x.CreditNoteDate).NotEmpty().WithMessage("Date is required"); + RuleFor(x => x.SalesReturnId).NotEmpty().WithMessage("Sales Return reference is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/DeleteCreditNoteByIdHandler.cs b/Features/Sales/CreditNote/Cqrs/DeleteCreditNoteByIdHandler.cs new file mode 100644 index 0000000..e621e64 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/DeleteCreditNoteByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public record DeleteCreditNoteByIdCommand(string Id) : IRequest; + +public class DeleteCreditNoteByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteCreditNoteByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCreditNoteByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CreditNote + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.CreditNote.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/GetCreditNoteByIdHandler.cs b/Features/Sales/CreditNote/Cqrs/GetCreditNoteByIdHandler.cs new file mode 100644 index 0000000..60170f2 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/GetCreditNoteByIdHandler.cs @@ -0,0 +1,135 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class CreditNoteItemResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public decimal UnitPrice { get; set; } + public double Quantity { get; set; } + public decimal Total { get; set; } +} + +public class GetCreditNoteByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? CreditNoteDate { get; set; } + public Data.Enums.CreditNoteStatus CreditNoteStatus { get; set; } + public string? Description { get; set; } + public string? SalesReturnId { get; set; } + public string? SalesReturnNumber { 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 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 GetCreditNoteByIdQuery(string Id) : IRequest; + +public class GetCreditNoteByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetCreditNoteByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCreditNoteByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + var creditNote = await _context.CreditNote + .AsNoTracking() + .Include(x => x.SalesReturn) + .ThenInclude(sr => sr!.DeliveryOrder) + .ThenInclude(do_ => do_!.SalesOrder) + .ThenInclude(so => so!.Customer) + .Include(x => x.SalesReturn) + .ThenInclude(sr => sr!.DeliveryOrder) + .ThenInclude(do_ => do_!.SalesOrder) + .ThenInclude(so => so!.Tax) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (creditNote == null) return null; + + var salesOrder = creditNote.SalesReturn?.DeliveryOrder?.SalesOrder; + var taxRate = salesOrder?.Tax?.PercentageValue ?? 0; + + var transactions = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == creditNote.SalesReturnId && x.ModuleName == nameof(Data.Entities.SalesReturn)) + .ToListAsync(cancellationToken); + + var items = new List(); + decimal subTotal = 0; + + foreach (var trans in transactions) + { + var soItem = await _context.SalesOrderItem + .AsNoTracking() + .FirstOrDefaultAsync(x => x.SalesOrderId == salesOrder!.Id && x.ProductId == trans.ProductId, cancellationToken); + + var unitPrice = soItem?.UnitPrice ?? 0; + var qty = Math.Abs(trans.Movement ?? 0); + var total = unitPrice * (decimal)qty; + + items.Add(new CreditNoteItemResponse + { + Id = trans.Id, + ProductId = trans.ProductId, + ProductName = trans.Product?.Name, + UnitPrice = unitPrice, + Quantity = qty, + Total = total + }); + + subTotal += total; + } + + var taxAmount = subTotal * (decimal)(taxRate / 100); + + return new GetCreditNoteByIdResponse + { + Id = creditNote.Id, + AutoNumber = creditNote.AutoNumber, + CreditNoteDate = creditNote.CreditNoteDate, + CreditNoteStatus = creditNote.CreditNoteStatus, + Description = creditNote.Description, + SalesReturnId = creditNote.SalesReturnId, + SalesReturnNumber = creditNote.SalesReturn?.AutoNumber, + 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", + BeforeTaxAmount = subTotal, + TaxAmount = taxAmount, + AfterTaxAmount = subTotal + taxAmount, + CreatedAt = creditNote.CreatedAt, + CreatedBy = creditNote.CreatedBy, + UpdatedAt = creditNote.UpdatedAt, + UpdatedBy = creditNote.UpdatedBy, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/GetCreditNoteListHandler.cs b/Features/Sales/CreditNote/Cqrs/GetCreditNoteListHandler.cs new file mode 100644 index 0000000..703127f --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/GetCreditNoteListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class GetCreditNoteListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? CreditNoteDate { get; set; } + public string? SalesReturnNumber { get; set; } + public string? CustomerName { get; set; } + public Data.Enums.CreditNoteStatus CreditNoteStatus { get; set; } +} + +public record GetCreditNoteListQuery() : IRequest>; + +public class GetCreditNoteListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetCreditNoteListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCreditNoteListQuery request, CancellationToken cancellationToken) + { + return await _context.CreditNote + .AsNoTracking() + .Include(x => x.SalesReturn) + .ThenInclude(sr => sr!.DeliveryOrder) + .ThenInclude(do_ => do_!.SalesOrder) + .ThenInclude(so => so!.Customer) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetCreditNoteListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + CreditNoteDate = x.CreditNoteDate, + SalesReturnNumber = x.SalesReturn!.AutoNumber, + CustomerName = x.SalesReturn!.DeliveryOrder!.SalesOrder!.Customer!.Name, + CreditNoteStatus = x.CreditNoteStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/GetCreditNoteLookupHandler.cs b/Features/Sales/CreditNote/Cqrs/GetCreditNoteLookupHandler.cs new file mode 100644 index 0000000..98ecf60 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/GetCreditNoteLookupHandler.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.CreditNote.Cqrs; + +public class CreditNoteLookupResponse +{ + public List SalesReturns { 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 GetCreditNoteLookupQuery() : IRequest; + +public class GetCreditNoteLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetCreditNoteLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCreditNoteLookupQuery request, CancellationToken cancellationToken) + { + var response = new CreditNoteLookupResponse(); + + response.SalesReturns = await _context.SalesReturn.AsNoTracking() + .Where(x => x.Status == SalesReturnStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Statuses = Enum.GetValues(typeof(CreditNoteStatus)) + .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/CreditNote/Cqrs/GetSalesReturnDetailForCreditNoteHandler.cs b/Features/Sales/CreditNote/Cqrs/GetSalesReturnDetailForCreditNoteHandler.cs new file mode 100644 index 0000000..0bee81d --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/GetSalesReturnDetailForCreditNoteHandler.cs @@ -0,0 +1,72 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public record GetSalesReturnDetailForCreditNoteQuery(string SalesReturnId) : IRequest; + +public class GetSalesReturnDetailForCreditNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesReturnDetailForCreditNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesReturnDetailForCreditNoteQuery request, CancellationToken cancellationToken) + { + var salesReturn = await _context.SalesReturn + .AsNoTracking() + .Include(x => x.DeliveryOrder) + .ThenInclude(do_ => do_!.SalesOrder) + .ThenInclude(so => so!.Tax) + .FirstOrDefaultAsync(x => x.Id == request.SalesReturnId, cancellationToken); + + if (salesReturn == null) return null; + + var salesOrder = salesReturn.DeliveryOrder?.SalesOrder; + var taxRate = salesOrder?.Tax?.PercentageValue ?? 0; + + var transactions = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Where(x => x.ModuleId == request.SalesReturnId && x.ModuleName == nameof(Data.Entities.SalesReturn)) + .ToListAsync(cancellationToken); + + var items = new List(); + decimal subTotal = 0; + + foreach (var trans in transactions) + { + var soItem = await _context.SalesOrderItem + .AsNoTracking() + .FirstOrDefaultAsync(x => x.SalesOrderId == salesOrder!.Id && x.ProductId == trans.ProductId, cancellationToken); + + var unitPrice = soItem?.UnitPrice ?? 0; + var qty = Math.Abs(trans.Movement ?? 0); + var total = unitPrice * (decimal)qty; + + items.Add(new CreditNoteItemResponse + { + Id = trans.Id, + ProductId = trans.ProductId, + ProductName = trans.Product?.Name, + UnitPrice = unitPrice, + Quantity = qty, + Total = total + }); + + subTotal += total; + } + + var taxAmount = subTotal * (decimal)(taxRate / 100); + + return new GetCreditNoteByIdResponse + { + SalesReturnId = salesReturn.Id, + SalesReturnNumber = salesReturn.AutoNumber, + BeforeTaxAmount = subTotal, + TaxAmount = taxAmount, + AfterTaxAmount = subTotal + taxAmount, + Items = items + }; + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteHandler.cs b/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteHandler.cs new file mode 100644 index 0000000..9d74348 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class UpdateCreditNoteRequest +{ + public string? Id { get; set; } + public DateTime? CreditNoteDate { get; set; } + public Data.Enums.CreditNoteStatus CreditNoteStatus { get; set; } + public string? AutoNumber { get; set; } + public string? Description { get; set; } + public string? SalesReturnId { 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 UpdateCreditNoteCommand(UpdateCreditNoteRequest Data) : IRequest; + +public class UpdateCreditNoteHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateCreditNoteHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCreditNoteCommand request, CancellationToken cancellationToken) + { + var entity = await _context.CreditNote + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.CreditNoteDate = request.Data.CreditNoteDate; + entity.CreditNoteStatus = request.Data.CreditNoteStatus; + entity.Description = request.Data.Description; + entity.SalesReturnId = request.Data.SalesReturnId; + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteValidator.cs b/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteValidator.cs new file mode 100644 index 0000000..44a5a74 --- /dev/null +++ b/Features/Sales/CreditNote/Cqrs/UpdateCreditNoteValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.CreditNote.Cqrs; + +public class UpdateCreditNoteValidator : AbstractValidator +{ + public UpdateCreditNoteValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.SalesReturnId).NotEmpty().WithMessage("Sales Return reference is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/CreditNoteEndpoint.cs b/Features/Sales/CreditNote/CreditNoteEndpoint.cs new file mode 100644 index 0000000..babb131 --- /dev/null +++ b/Features/Sales/CreditNote/CreditNoteEndpoint.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.CreditNote.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.CreditNote; + +public static class CreditNoteEndpoint +{ + public static void MapCreditNoteEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/credit-note").WithTags("CreditNotes") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCreditNoteListQuery()); + return result.ToApiResponse("Credit note list retrieved successfully"); + }) + .WithName("GetCreditNoteList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCreditNoteByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Credit note detail retrieved successfully" + : $"Credit note with ID {id} not found"); + }) + .WithName("GetCreditNoteById"); + + group.MapGet("/sales-return-detail/{srId}", async (string srId, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReturnDetailForCreditNoteQuery(srId)); + return result.ToApiResponse(result is not null + ? "Sales return detail for credit note retrieved successfully" + : $"Sales return with ID {srId} not found"); + }) + .WithName("GetSalesReturnDetailForCreditNote"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCreditNoteLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetCreditNoteLookup"); + + group.MapPost("/", async (CreateCreditNoteRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCreditNoteCommand(request)); + return result.ToApiResponse("Credit note has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCreditNote"); + + group.MapPost("/update", async (UpdateCreditNoteRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCreditNoteCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Credit note not found."); + } + return result.ToApiResponse("Credit note has been updated successfully"); + }) + .WithName("UpdateCreditNote"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCreditNoteByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Credit note not found."); + } + return true.ToApiResponse("Credit note has been deleted successfully"); + }) + .WithName("DeleteCreditNoteById"); + } +} \ No newline at end of file diff --git a/Features/Sales/CreditNote/CreditNotePdfGenerator.cs b/Features/Sales/CreditNote/CreditNotePdfGenerator.cs new file mode 100644 index 0000000..2b21fbc --- /dev/null +++ b/Features/Sales/CreditNote/CreditNotePdfGenerator.cs @@ -0,0 +1,126 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.CreditNote.Cqrs; + +namespace Indotalent.Features.Sales.CreditNote; + +public class CreditNotePdfGenerator +{ + public static byte[] Generate(GetCreditNoteByIdResponse 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("CREDIT NOTE") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + col.Item().Text($"Sales Return Ref: {data.SalesReturnNumber}").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("BILL 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); + }); + + row.ConstantItem(40); + + row.RelativeItem().Column(c => + { + c.Item().BorderBottom(1).PaddingBottom(5).Text("DOCUMENT DETAILS").FontSize(8).Bold().FontColor(textSlate); + c.Item().PaddingTop(5).Row(r => { + r.RelativeItem().Text("Credit Note Date"); + r.RelativeItem().AlignRight().Text(data.CreditNoteDate?.ToString("dd MMM yyyy")); + }); + c.Item().Row(r => { + r.RelativeItem().Text("Status"); + r.RelativeItem().AlignRight().Text(data.CreditNoteStatus.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("Returned 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).Text(item.ProductName).Bold(); + 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/CreditNote/CreditNoteService.cs b/Features/Sales/CreditNote/CreditNoteService.cs new file mode 100644 index 0000000..696f57c --- /dev/null +++ b/Features/Sales/CreditNote/CreditNoteService.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.CreditNote.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.CreditNote; + +public class CreditNoteService : BaseService +{ + private readonly RestClient _client; + + public CreditNoteService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCreditNoteListAsync() + { + var request = new RestRequest("api/credit-note", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCreditNoteByIdAsync(string id) + { + var request = new RestRequest($"api/credit-note/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesReturnDetailForCreditNoteAsync(string srId) + { + var request = new RestRequest($"api/credit-note/sales-return-detail/{srId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetCreditNoteLookupAsync() + { + var request = new RestRequest("api/credit-note/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCreditNoteAsync(CreateCreditNoteRequest data) + { + var request = new RestRequest("api/credit-note", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateCreditNoteAsync(UpdateCreditNoteRequest data) + { + var request = new RestRequest("api/credit-note/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCreditNoteByIdAsync(string id) + { + var request = new RestRequest($"api/credit-note/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/DeliveryOrder/Components/DeliveryOrderPage.razor b/Features/Sales/DeliveryOrder/Components/DeliveryOrderPage.razor new file mode 100644 index 0000000..8b890d2 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/DeliveryOrderPage.razor @@ -0,0 +1,51 @@ +@page "/sales/delivery-order" +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using Indotalent.Features.Sales.DeliveryOrder.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_DeliveryOrderCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_DeliveryOrderUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_DeliveryOrderDataTable 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 UpdateDeliveryOrderRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateDeliveryOrderRequest 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/DeliveryOrder/Components/_DeliveryOrderCreateForm.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderCreateForm.razor new file mode 100644 index 0000000..b2a8090 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DeliveryOrderService DeliveryOrderService +@inject ISnackbar Snackbar + + +
+ +
+ Add Delivery Order + Initiate a new delivery process from a confirmed sales order. +
+
+
+ + + + + Basic Information + + + Delivery Date + + + + + Sales Order Reference + + @foreach (var item in _lookup.SalesOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Shipping Notes + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Delivery Order + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateDeliveryOrderValidator _validator = new(); + private CreateDeliveryOrderRequest _model = new() { DeliveryDate = DateTime.Today }; + private DeliveryOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await DeliveryOrderService.GetDeliveryOrderLookupAsync(); + 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 DeliveryOrderService.CreateDeliveryOrderAsync(_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/DeliveryOrder/Components/_DeliveryOrderDataTable.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderDataTable.razor new file mode 100644 index 0000000..85bd2dc --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderDataTable.razor @@ -0,0 +1,379 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.DeliveryOrder +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DeliveryOrderService DeliveryOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Delivery Order + Manage shipping process and inventory stock outflows. +
+
+ + / + Sales + / + Delivery Order +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Delivery Order + + } +
+
+ + + + + + Number + + + Date + + + SO Number + + + Status + + + + + + + @context.AutoNumber + @context.DeliveryDate?.ToString("yyyy-MM-dd") + @context.SalesOrderNumber + + @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 GetDeliveryOrderListResponse? _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 DeliveryOrderService.GetDeliveryOrderListAsync(); + 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.SalesOrderNumber?.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("DeliveryOrders"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "SO Reference"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.DeliveryDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.SalesOrderNumber; + worksheet.Cell(currentRow, 4).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", "DO_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 DeliveryOrderService.GetDeliveryOrderByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateDeliveryOrderRequest + { + Id = detail.Id, + DeliveryDate = detail.DeliveryDate, + Status = detail.Status, + Description = detail.Description, + SalesOrderId = detail.SalesOrderId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 DeliveryOrderService.DeleteDeliveryOrderByIdAsync(_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/DeliveryOrder/Components/_DeliveryOrderItemCreateForm.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemCreateForm.razor new file mode 100644 index 0000000..0f23aea --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using MudBlazor +@inject DeliveryOrderService DeliveryOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Movement Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Line + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string DeliveryOrderId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateDeliveryOrderItemRequest _model = new() { Movement = 1 }; + private DeliveryOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await DeliveryOrderService.GetDeliveryOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.DeliveryOrderId = DeliveryOrderId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DeliveryOrderService.CreateDeliveryOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemDataTable.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemDataTable.razor new file mode 100644 index 0000000..c2b7c07 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.DeliveryOrder +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject DeliveryOrderService DeliveryOrderService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Items List + @if (!ReadOnly) + { + + Add Movement Line + + } +
+ + + + Product + Warehouse + Quantity + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string DeliveryOrderId { 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 { ["DeliveryOrderId"] = DeliveryOrderId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_DeliveryOrderItemCreateForm>("Add Item Movement", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(DeliveryOrderInvenTransResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_DeliveryOrderItemUpdateForm>("Edit Item Movement", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(DeliveryOrderInvenTransResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this movement 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 DeliveryOrderService.DeleteDeliveryOrderItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemUpdateForm.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemUpdateForm.razor new file mode 100644 index 0000000..bea8690 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using MudBlazor +@inject DeliveryOrderService DeliveryOrderService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Movement Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public DeliveryOrderInvenTransResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateDeliveryOrderItemRequest _model = new(); + private DeliveryOrderLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await DeliveryOrderService.GetDeliveryOrderLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DeliveryOrderService.UpdateDeliveryOrderItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Components/_DeliveryOrderUpdateForm.razor b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderUpdateForm.razor new file mode 100644 index 0000000..4629dca --- /dev/null +++ b/Features/Sales/DeliveryOrder/Components/_DeliveryOrderUpdateForm.razor @@ -0,0 +1,263 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.DeliveryOrder.Cqrs +@using Indotalent.Features.Sales.DeliveryOrder.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject DeliveryOrderService DeliveryOrderService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Delivery Order + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Delivery Date + + + + + Sales Order Reference + + @foreach (var item in _lookup.SalesOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Shipping Notes + + + + + <_DeliveryOrderItemDataTable Items="_items" DeliveryOrderId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Items + @_items.Count +
+
+ Total Movement Quantity + @_items.Sum(x => x.Movement) +
+ +
+ Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdateDeliveryOrderRequest 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 UpdateDeliveryOrderValidator _validator = new(); + private UpdateDeliveryOrderRequest _model = new(); + private List _items = new(); + private DeliveryOrderLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await DeliveryOrderService.GetDeliveryOrderLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await DeliveryOrderService.GetDeliveryOrderByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 DeliveryOrderService.GetDeliveryOrderByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = DeliveryOrderPdfGenerator.Generate(response.Value); + var fileName = $"DO_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await DeliveryOrderService.UpdateDeliveryOrderAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/DeliveryOrder/Cqrs/CreateDeliveryOrderHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderHandler.cs new file mode 100644 index 0000000..fa5133e --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class CreateDeliveryOrderRequest +{ + public DateTime? DeliveryDate { get; set; } + public Data.Enums.DeliveryOrderStatus Status { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } +} + +public class CreateDeliveryOrderResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateDeliveryOrderCommand(CreateDeliveryOrderRequest Data) : IRequest; + +public class CreateDeliveryOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateDeliveryOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.DeliveryOrder); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.DeliveryOrder + { + AutoNumber = autoNo, + DeliveryDate = request.Data.DeliveryDate, + Status = request.Data.Status, + Description = request.Data.Description, + SalesOrderId = request.Data.SalesOrderId + }; + + _context.DeliveryOrder.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateDeliveryOrderResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderItemHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderItemHandler.cs new file mode 100644 index 0000000..1b6bc30 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class CreateDeliveryOrderItemRequest +{ + public string? DeliveryOrderId { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateDeliveryOrderItemCommand(CreateDeliveryOrderItemRequest Data) : IRequest; + +public class CreateDeliveryOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateDeliveryOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDeliveryOrderItemCommand request, CancellationToken cancellationToken) + { + var parent = await _context.DeliveryOrder + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.DeliveryOrderId, cancellationToken); + + if (parent == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + AutoNumber = autoNo, + ModuleId = parent.Id, + ModuleName = nameof(Data.Entities.DeliveryOrder), + ModuleCode = "DO", + ModuleNumber = parent.AutoNumber, + MovementDate = parent.DeliveryDate ?? DateTime.Now, + Status = (Data.Enums.InventoryTransactionStatus)parent.Status, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderValidator.cs b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderValidator.cs new file mode 100644 index 0000000..ed8ba6b --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/CreateDeliveryOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class CreateDeliveryOrderValidator : AbstractValidator +{ + public CreateDeliveryOrderValidator() + { + RuleFor(x => x.DeliveryDate).NotEmpty().WithMessage("Delivery Date is required"); + RuleFor(x => x.SalesOrderId).NotEmpty().WithMessage("Sales Order reference is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderByIdHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderByIdHandler.cs new file mode 100644 index 0000000..b57f75b --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderByIdHandler.cs @@ -0,0 +1,31 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public record DeleteDeliveryOrderByIdCommand(string Id) : IRequest; + +public class DeleteDeliveryOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteDeliveryOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDeliveryOrderByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.DeliveryOrder + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.DeliveryOrder)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(transactions); + _context.DeliveryOrder.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderItemHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderItemHandler.cs new file mode 100644 index 0000000..0682868 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/DeleteDeliveryOrderItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public record DeleteDeliveryOrderItemCommand(string Id) : IRequest; + +public class DeleteDeliveryOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteDeliveryOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDeliveryOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderByIdHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderByIdHandler.cs new file mode 100644 index 0000000..5a3c912 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class DeliveryOrderInvenTransResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetDeliveryOrderByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? DeliveryDate { get; set; } + public Data.Enums.DeliveryOrderStatus Status { get; set; } + public string? Description { get; set; } + public string? SalesOrderId { get; set; } + public string? SalesOrderNumber { 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 GetDeliveryOrderByIdQuery(string Id) : IRequest; + +public class GetDeliveryOrderByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetDeliveryOrderByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.DeliveryOrder + .AsNoTracking() + .Include(x => x.SalesOrder) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var details = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.DeliveryOrder)) + .Select(x => new DeliveryOrderInvenTransResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }) + .ToListAsync(cancellationToken); + + return new GetDeliveryOrderByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + DeliveryDate = master.DeliveryDate, + Status = master.Status, + Description = master.Description, + SalesOrderId = master.SalesOrderId, + SalesOrderNumber = master.SalesOrder?.AutoNumber, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = details + }; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderListHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderListHandler.cs new file mode 100644 index 0000000..102c9af --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderListHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class GetDeliveryOrderListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? DeliveryDate { get; set; } + public string? SalesOrderNumber { get; set; } + public Data.Enums.DeliveryOrderStatus Status { get; set; } +} + +public record GetDeliveryOrderListQuery() : IRequest>; + +public class GetDeliveryOrderListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetDeliveryOrderListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetDeliveryOrderListQuery request, CancellationToken cancellationToken) + { + return await _context.DeliveryOrder + .AsNoTracking() + .Include(x => x.SalesOrder) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetDeliveryOrderListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + DeliveryDate = x.DeliveryDate, + SalesOrderNumber = x.SalesOrder!.AutoNumber, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderLookupHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderLookupHandler.cs new file mode 100644 index 0000000..585441b --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/GetDeliveryOrderLookupHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class DeliveryOrderLookupResponse +{ + public List SalesOrders { get; set; } = new(); + public List Warehouses { 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 GetDeliveryOrderLookupQuery() : IRequest; + +public class GetDeliveryOrderLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetDeliveryOrderLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDeliveryOrderLookupQuery request, CancellationToken cancellationToken) + { + var response = new DeliveryOrderLookupResponse(); + + 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.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .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(DeliveryOrderStatus)) + .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/DeliveryOrder/Cqrs/UpdateDeliveryOrderHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderHandler.cs new file mode 100644 index 0000000..1cecf00 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class UpdateDeliveryOrderRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? DeliveryDate { get; set; } + public Data.Enums.DeliveryOrderStatus Status { 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 UpdateDeliveryOrderCommand(UpdateDeliveryOrderRequest Data) : IRequest; + +public class UpdateDeliveryOrderHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateDeliveryOrderHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken) + { + var entity = await _context.DeliveryOrder + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.DeliveryDate = request.Data.DeliveryDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.SalesOrderId = request.Data.SalesOrderId; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.DeliveryOrder)) + .ToListAsync(cancellationToken); + + foreach (var trans in transactions) + { + trans.MovementDate = entity.DeliveryDate ?? DateTime.Now; + trans.ModuleNumber = entity.AutoNumber; + trans.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + _context.CalculateInvenTrans(trans); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderItemHandler.cs b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderItemHandler.cs new file mode 100644 index 0000000..1ff4074 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class UpdateDeliveryOrderItemRequest +{ + public string? Id { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateDeliveryOrderItemCommand(UpdateDeliveryOrderItemRequest Data) : IRequest; + +public class UpdateDeliveryOrderItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateDeliveryOrderItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDeliveryOrderItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.WarehouseId = request.Data.WarehouseId; + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderValidator.cs b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderValidator.cs new file mode 100644 index 0000000..8b72529 --- /dev/null +++ b/Features/Sales/DeliveryOrder/Cqrs/UpdateDeliveryOrderValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +public class UpdateDeliveryOrderValidator : AbstractValidator +{ + public UpdateDeliveryOrderValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.SalesOrderId).NotEmpty(); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/DeliveryOrderEndpoint.cs b/Features/Sales/DeliveryOrder/DeliveryOrderEndpoint.cs new file mode 100644 index 0000000..97a3184 --- /dev/null +++ b/Features/Sales/DeliveryOrder/DeliveryOrderEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.DeliveryOrder.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.DeliveryOrder; + +public static class DeliveryOrderEndpoint +{ + public static void MapDeliveryOrderEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/delivery-order").WithTags("DeliveryOrders") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDeliveryOrderListQuery()); + return result.ToApiResponse("Delivery order list retrieved successfully"); + }) + .WithName("GetDeliveryOrderList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetDeliveryOrderByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Delivery order detail retrieved successfully" + : $"Delivery order with ID {id} not found"); + }) + .WithName("GetDeliveryOrderById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDeliveryOrderLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetDeliveryOrderLookup"); + + group.MapPost("/", async (CreateDeliveryOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateDeliveryOrderCommand(request)); + return result.ToApiResponse("Delivery order has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateDeliveryOrder"); + + group.MapPost("/update", async (UpdateDeliveryOrderRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateDeliveryOrderCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Delivery order not found."); + } + return result.ToApiResponse("Delivery order has been updated successfully"); + }) + .WithName("UpdateDeliveryOrder"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteDeliveryOrderByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Delivery order not found."); + } + return true.ToApiResponse("Delivery order has been deleted successfully"); + }) + .WithName("DeleteDeliveryOrderById"); + + group.MapPost("/delivery-order-item", async (CreateDeliveryOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateDeliveryOrderItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateDeliveryOrderItem"); + + group.MapPost("/delivery-order-item/update", async (UpdateDeliveryOrderItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateDeliveryOrderItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateDeliveryOrderItem"); + + group.MapPost("/delivery-order-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteDeliveryOrderItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteDeliveryOrderItem"); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryOrder/DeliveryOrderPdfGenerator.cs b/Features/Sales/DeliveryOrder/DeliveryOrderPdfGenerator.cs new file mode 100644 index 0000000..565a3c6 --- /dev/null +++ b/Features/Sales/DeliveryOrder/DeliveryOrderPdfGenerator.cs @@ -0,0 +1,110 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.DeliveryOrder.Cqrs; + +namespace Indotalent.Features.Sales.DeliveryOrder; + +public class DeliveryOrderPdfGenerator +{ + public static byte[] Generate(GetDeliveryOrderByIdResponse 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("DELIVERY ORDER") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("SHIPPING SLIP").FontSize(11).ExtraBold(); + col.Item().Text($"SO Reference: {data.SalesOrderNumber}").FontColor(textSlate); + col.Item().Text($"Date: {data.DeliveryDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().PaddingTop(10).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Name"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(20).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($"Status: {data.Status.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryBlue); + }); + + row.ConstantItem(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Issued By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Customer / Consignee )").FontSize(7); + }); + }); + }); + + 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/DeliveryOrder/DeliveryOrderService.cs b/Features/Sales/DeliveryOrder/DeliveryOrderService.cs new file mode 100644 index 0000000..030896e --- /dev/null +++ b/Features/Sales/DeliveryOrder/DeliveryOrderService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.DeliveryOrder.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.DeliveryOrder; + +public class DeliveryOrderService : BaseService +{ + private readonly RestClient _client; + + public DeliveryOrderService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDeliveryOrderListAsync() + { + var request = new RestRequest("api/delivery-order", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetDeliveryOrderByIdAsync(string id) + { + var request = new RestRequest($"api/delivery-order/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetDeliveryOrderLookupAsync() + { + var request = new RestRequest("api/delivery-order/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateDeliveryOrderAsync(CreateDeliveryOrderRequest data) + { + var request = new RestRequest("api/delivery-order", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateDeliveryOrderAsync(UpdateDeliveryOrderRequest data) + { + var request = new RestRequest("api/delivery-order/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDeliveryOrderByIdAsync(string id) + { + var request = new RestRequest($"api/delivery-order/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateDeliveryOrderItemAsync(CreateDeliveryOrderItemRequest data) + { + var request = new RestRequest("api/delivery-order/delivery-order-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateDeliveryOrderItemAsync(UpdateDeliveryOrderItemRequest data) + { + var request = new RestRequest("api/delivery-order/delivery-order-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDeliveryOrderItemAsync(string id) + { + var request = new RestRequest($"api/delivery-order/delivery-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/DeliveryReport/Components/DeliveryReportPage.razor b/Features/Sales/DeliveryReport/Components/DeliveryReportPage.razor new file mode 100644 index 0000000..0ec2846 --- /dev/null +++ b/Features/Sales/DeliveryReport/Components/DeliveryReportPage.razor @@ -0,0 +1,8 @@ +@page "/sales/delivery-report" +@using Indotalent.Features.Sales.DeliveryReport.Components +@using MudBlazor + +<_DeliveryReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Sales/DeliveryReport/Components/_DeliveryReportDataTable.razor b/Features/Sales/DeliveryReport/Components/_DeliveryReportDataTable.razor new file mode 100644 index 0000000..a04115a --- /dev/null +++ b/Features/Sales/DeliveryReport/Components/_DeliveryReportDataTable.razor @@ -0,0 +1,301 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.DeliveryReport +@using Indotalent.Features.Sales.DeliveryReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DeliveryReportService DeliveryReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Delivery Report + Tracking stock movements for delivery orders and sales fulfillment. +
+
+ + / + Sales + / + Delivery Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Delivery Order + + + Sales Order + + + Customer + + + Warehouse + + + Product Number + + + Product Name + + + Qty + + + Delivery Date + + + + @context.DeliveryOrderNumber + @context.SalesOrderNumber + @context.CustomerName + @context.WarehouseName + @context.ProductNumber + @context.ProductName + @context.Quantity?.ToString("N2") + @context.DeliveryDate?.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 DeliveryReportService.GetDeliveryReportListAsync(); + 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.DeliveryOrderNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.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("DeliveryReports"); + var currentRow = 1; + + string[] headers = { "Delivery Order", "Sales Order", "Customer", "Warehouse", "Product Number", "Product Name", "Quantity", "Delivery 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.DeliveryOrderNumber; + worksheet.Cell(currentRow, 2).Value = item.SalesOrderNumber; + worksheet.Cell(currentRow, 3).Value = item.CustomerName; + worksheet.Cell(currentRow, 4).Value = item.WarehouseName; + worksheet.Cell(currentRow, 5).Value = item.ProductNumber; + worksheet.Cell(currentRow, 6).Value = item.ProductName; + worksheet.Cell(currentRow, 7).Value = item.Quantity; + worksheet.Cell(currentRow, 8).Value = item.DeliveryDate?.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", "Delivery_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/DeliveryReport/Cqrs/GetDeliveryReportListHandler.cs b/Features/Sales/DeliveryReport/Cqrs/GetDeliveryReportListHandler.cs new file mode 100644 index 0000000..17b6953 --- /dev/null +++ b/Features/Sales/DeliveryReport/Cqrs/GetDeliveryReportListHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.DeliveryReport.Cqrs; + +public record GetDeliveryReportListDto +{ + public string? Id { get; init; } + public string? DeliveryOrderNumber { get; init; } + public string? SalesOrderNumber { get; init; } + public string? CustomerName { get; init; } + public string? WarehouseName { get; init; } + public string? ProductNumber { get; init; } + public string? ProductName { get; init; } + public double? Quantity { get; init; } + public DateTime? DeliveryDate { get; init; } +} + +public record GetDeliveryReportListQuery() : IRequest>; + +public class GetDeliveryReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetDeliveryReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetDeliveryReportListQuery request, CancellationToken cancellationToken) + { + var query = _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleName == "DeliveryOrder") + .GroupJoin( + _context.DeliveryOrder.AsNoTracking() + .Include(x => x.SalesOrder) + .ThenInclude(x => x!.Customer), + inventory => inventory.ModuleId, + delivery => delivery.Id, + (inventory, deliveries) => new { inventory, deliveries } + ) + .SelectMany( + x => x.deliveries.DefaultIfEmpty(), + (x, delivery) => new GetDeliveryReportListDto + { + Id = x.inventory.Id, + DeliveryOrderNumber = delivery != null ? delivery.AutoNumber : x.inventory.ModuleNumber, + SalesOrderNumber = (delivery != null && delivery.SalesOrder != null) ? delivery.SalesOrder.AutoNumber : string.Empty, + CustomerName = (delivery != null && delivery.SalesOrder != null && delivery.SalesOrder.Customer != null) ? delivery.SalesOrder.Customer.Name : string.Empty, + WarehouseName = x.inventory.Warehouse != null ? x.inventory.Warehouse.Name : string.Empty, + ProductNumber = x.inventory.Product != null ? x.inventory.Product.AutoNumber : string.Empty, + ProductName = x.inventory.Product != null ? x.inventory.Product.Name : string.Empty, + Quantity = x.inventory.Movement, + DeliveryDate = delivery != null ? delivery.DeliveryDate : x.inventory.MovementDate + } + ) + .OrderByDescending(x => x.DeliveryDate); + + return await query.ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryReport/DeliveryReportEndpoint.cs b/Features/Sales/DeliveryReport/DeliveryReportEndpoint.cs new file mode 100644 index 0000000..7d0d3e0 --- /dev/null +++ b/Features/Sales/DeliveryReport/DeliveryReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.DeliveryReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.DeliveryReport; + +public static class DeliveryReportEndpoint +{ + public static void MapDeliveryReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/delivery-report").WithTags("DeliveryReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDeliveryReportListQuery()); + return result.ToApiResponse("Delivery report list retrieved successfully"); + }) + .WithName("GetDeliveryReportList"); + } +} \ No newline at end of file diff --git a/Features/Sales/DeliveryReport/DeliveryReportService.cs b/Features/Sales/DeliveryReport/DeliveryReportService.cs new file mode 100644 index 0000000..c7bfcdc --- /dev/null +++ b/Features/Sales/DeliveryReport/DeliveryReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.DeliveryReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.DeliveryReport; + +public class DeliveryReportService : BaseService +{ + private readonly RestClient _client; + + public DeliveryReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDeliveryReportListAsync() + { + var request = new RestRequest("api/delivery-report", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ 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..a9705d3 --- /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..83ec740 --- /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..9c2fbc7 --- /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..5ee3430 --- /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..746c2af --- /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..b32cb05 --- /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..43dba5c --- /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..af22a97 --- /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..9b896fb --- /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..bbcbe52 --- /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..6c61f6c --- /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..3b7a341 --- /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..c594c67 --- /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..7c55c01 --- /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..53cc71c --- /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..eb1a1e0 --- /dev/null +++ b/Features/Sales/SalesPage.razor @@ -0,0 +1,147 @@ +@page "/sales" +@using Indotalent.Features.Sales.CreditNote.Components +@using Indotalent.Features.Sales.DeliveryOrder.Components +@using Indotalent.Features.Sales.DeliveryReport.Components +@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.SalesQuotation.Components +@using Indotalent.Features.Sales.SalesReport.Components +@using Indotalent.Features.Sales.SalesReturn.Components +@using Indotalent.Features.Sales.SalesReturnReport.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, "quotation" }, + { 1, "order" }, + { 2, "delivery" }, + { 3, "return" }, + { 4, "invoice" }, + { 5, "credit-note" }, + { 6, "payment" }, + { 7, "report-sales" }, + { 8, "report-delivery" }, + { 9, "report-return" }, + { 10, "report-invoice" }, + { 11, "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/SalesQuotation/Components/SalesQuotationPage.razor b/Features/Sales/SalesQuotation/Components/SalesQuotationPage.razor new file mode 100644 index 0000000..7c1ad3c --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/SalesQuotationPage.razor @@ -0,0 +1,51 @@ +@page "/sales/sales-quotation" +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using Indotalent.Features.Sales.SalesQuotation.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SalesQuotationCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SalesQuotationUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_SalesQuotationDataTable 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 UpdateSalesQuotationRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateSalesQuotationRequest 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/SalesQuotation/Components/_SalesQuotationCreateForm.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationCreateForm.razor new file mode 100644 index 0000000..c28ae77 --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationCreateForm.razor @@ -0,0 +1,141 @@ +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesQuotationService SalesQuotationService +@inject ISnackbar Snackbar + + +
+ +
+ Add Sales Quotation + Create a new master sales quotation. +
+
+
+ + + + + Basic Information + + + Quotation 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 Quotation + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateSalesQuotationValidator _validator = new(); + private CreateSalesQuotationRequest _model = new() { QuotationDate = DateTime.Today }; + private SalesQuotationLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesQuotationService.GetSalesQuotationLookupAsync(); + 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 SalesQuotationService.CreateSalesQuotationAsync(_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/SalesQuotation/Components/_SalesQuotationDataTable.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationDataTable.razor new file mode 100644 index 0000000..84fb037 --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationDataTable.razor @@ -0,0 +1,388 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesQuotation +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesQuotationService SalesQuotationService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Quotation + Manage customer quotations and pricing proposals. +
+
+ + / + Sales + / + Sales Quotation +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Sales Quotation + + } +
+
+ + + + + + No + + + Date + + + Customer + + + Status + + + Total Amount + + + + + + + @context.AutoNumber + @context.QuotationDate?.ToString("yyyy-MM-dd") + @context.CustomerName + + @context.QuotationStatus.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 GetSalesQuotationListResponse? _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 SalesQuotationService.GetSalesQuotationListAsync(); + 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("SalesQuotations"); + 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.QuotationDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.CustomerName; + worksheet.Cell(currentRow, 4).Value = item.QuotationStatus.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", "SQ_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 SalesQuotationService.GetSalesQuotationByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateSalesQuotationRequest + { + Id = detail.Id, + QuotationDate = detail.QuotationDate, + QuotationStatus = detail.QuotationStatus, + 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 SalesQuotationService.DeleteSalesQuotationByIdAsync(_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/SalesQuotation/Components/_SalesQuotationItemCreateForm.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemCreateForm.razor new file mode 100644 index 0000000..544cac4 --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemCreateForm.razor @@ -0,0 +1,86 @@ +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using MudBlazor +@inject SalesQuotationService SalesQuotationService +@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 SalesQuotationId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateSalesQuotationItemRequest _model = new() { Quantity = 1 }; + private SalesQuotationLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesQuotationService.GetSalesQuotationLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.SalesQuotationId = SalesQuotationId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesQuotationService.CreateSalesQuotationItemAsync(_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/SalesQuotation/Components/_SalesQuotationItemDataTable.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemDataTable.razor new file mode 100644 index 0000000..4f1a610 --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemDataTable.razor @@ -0,0 +1,92 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesQuotation +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject SalesQuotationService SalesQuotationService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Quotation 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 SalesQuotationId { 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 { ["SalesQuotationId"] = SalesQuotationId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesQuotationItemCreateForm>("Add Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(SalesQuotationItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesQuotationItemUpdateForm>("Edit Item", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(SalesQuotationItemResponse 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 SalesQuotationService.DeleteSalesQuotationItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Components/_SalesQuotationItemUpdateForm.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemUpdateForm.razor new file mode 100644 index 0000000..30232f5 --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationItemUpdateForm.razor @@ -0,0 +1,96 @@ +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using MudBlazor +@inject SalesQuotationService SalesQuotationService +@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 SalesQuotationItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateSalesQuotationItemRequest _model = new(); + private SalesQuotationLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesQuotationService.GetSalesQuotationLookupAsync(); + 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 SalesQuotationService.UpdateSalesQuotationItemAsync(_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/SalesQuotation/Components/_SalesQuotationUpdateForm.razor b/Features/Sales/SalesQuotation/Components/_SalesQuotationUpdateForm.razor new file mode 100644 index 0000000..eeda97f --- /dev/null +++ b/Features/Sales/SalesQuotation/Components/_SalesQuotationUpdateForm.razor @@ -0,0 +1,300 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesQuotation.Cqrs +@using Indotalent.Features.Sales.SalesQuotation.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject SalesQuotationService SalesQuotationService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Quotation + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Quotation 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 + + + + + <_SalesQuotationItemDataTable Items="_items" SalesQuotationId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Quotation 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 UpdateSalesQuotationRequest 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 UpdateSalesQuotationValidator _validator = new(); + private UpdateSalesQuotationRequest _model = new(); + private List _items = new(); + private SalesQuotationLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await SalesQuotationService.GetSalesQuotationLookupAsync(); + 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 SalesQuotationService.GetSalesQuotationByIdAsync(_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 SalesQuotationService.GetSalesQuotationByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = SalesQuotationPdfGenerator.Generate(response.Value); + var fileName = $"SQ_{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 SalesQuotationService.UpdateSalesQuotationAsync(_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/SalesQuotation/Cqrs/CreateSalesQuotationHandler.cs b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationHandler.cs new file mode 100644 index 0000000..3cbe8f6 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class CreateSalesQuotationRequest +{ + public DateTime? QuotationDate { get; set; } + public Data.Enums.SalesQuotationStatus QuotationStatus { get; set; } + public string? Description { get; set; } + public string? CustomerId { get; set; } + public string? TaxId { get; set; } +} + +public class CreateSalesQuotationResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateSalesQuotationCommand(CreateSalesQuotationRequest Data) : IRequest; + +public class CreateSalesQuotationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesQuotationHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesQuotationCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.SalesQuotation); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"SQ/{{Year}}/{{Month}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.SalesQuotation + { + AutoNumber = autoNo, + QuotationDate = request.Data.QuotationDate, + QuotationStatus = request.Data.QuotationStatus, + Description = request.Data.Description, + CustomerId = request.Data.CustomerId, + TaxId = request.Data.TaxId, + BeforeTaxAmount = 0, + TaxAmount = 0, + AfterTaxAmount = 0 + }; + + _context.SalesQuotation.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesQuotationResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationItemHandler.cs b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationItemHandler.cs new file mode 100644 index 0000000..c5aff3e --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationItemHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class CreateSalesQuotationItemRequest +{ + public string? SalesQuotationId { get; set; } + public string? ProductId { get; set; } + public string? Summary { get; set; } + public decimal? UnitPrice { get; set; } + public double? Quantity { get; set; } +} + +public record CreateSalesQuotationItemCommand(CreateSalesQuotationItemRequest Data) : IRequest; + +public class CreateSalesQuotationItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesQuotationItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesQuotationItemCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.SalesQuotationItem + { + SalesQuotationId = request.Data.SalesQuotationId, + 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.SalesQuotationItem.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(request.Data.SalesQuotationId)) + { + SalesQuotationHelper.Recalculate(_context, request.Data.SalesQuotationId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationValidator.cs b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationValidator.cs new file mode 100644 index 0000000..1582c65 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/CreateSalesQuotationValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class CreateSalesQuotationValidator : AbstractValidator +{ + public CreateSalesQuotationValidator() + { + RuleFor(x => x.QuotationDate).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/SalesQuotation/Cqrs/DeleteSalesQuotationByIdHandler.cs b/Features/Sales/SalesQuotation/Cqrs/DeleteSalesQuotationByIdHandler.cs new file mode 100644 index 0000000..f84682b --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/DeleteSalesQuotationByIdHandler.cs @@ -0,0 +1,25 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public record DeleteSalesQuotationByIdCommand(string Id) : IRequest; + +public class DeleteSalesQuotationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesQuotationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesQuotationByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesQuotation + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.SalesQuotation.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/DeleteSalesQuotationItemHandler.cs b/Features/Sales/SalesQuotation/Cqrs/DeleteSalesQuotationItemHandler.cs new file mode 100644 index 0000000..d7013dc --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/DeleteSalesQuotationItemHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public record DeleteSalesQuotationItemCommand(string Id) : IRequest; + +public class DeleteSalesQuotationItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesQuotationItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesQuotationItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesQuotationItem + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var quotationId = entity.SalesQuotationId; + + _context.SalesQuotationItem.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(quotationId)) + { + SalesQuotationHelper.Recalculate(_context, quotationId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationByIdHandler.cs b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationByIdHandler.cs new file mode 100644 index 0000000..fde7d97 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationByIdHandler.cs @@ -0,0 +1,116 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class SalesQuotationItemResponse +{ + 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 GetSalesQuotationByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? QuotationDate { get; set; } + public Data.Enums.SalesQuotationStatus QuotationStatus { 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 GetSalesQuotationByIdQuery(string Id) : IRequest; + +public class GetSalesQuotationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesQuotationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesQuotationByIdQuery request, CancellationToken cancellationToken) + { + var company = await _context.Company + .AsNoTracking() + .Include(x => x.Currency) + .OrderByDescending(x => x.IsDefault) + .FirstOrDefaultAsync(cancellationToken); + + return await _context.SalesQuotation + .AsNoTracking() + .Include(x => x.Customer) + .Include(x => x.SalesQuotationItemList) + .ThenInclude(x => x.Product) + .Where(x => x.Id == request.Id) + .Select(x => new GetSalesQuotationByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + QuotationDate = x.QuotationDate, + QuotationStatus = x.QuotationStatus, + 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.SalesQuotationItemList.Select(i => new SalesQuotationItemResponse + { + 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/SalesQuotation/Cqrs/GetSalesQuotationListHandler.cs b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationListHandler.cs new file mode 100644 index 0000000..263b8a1 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationListHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class GetSalesQuotationListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? QuotationDate { get; set; } + public string? CustomerName { get; set; } + public decimal? AfterTaxAmount { get; set; } + public Data.Enums.SalesQuotationStatus QuotationStatus { get; set; } +} + +public record GetSalesQuotationListQuery() : IRequest>; + +public class GetSalesQuotationListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetSalesQuotationListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSalesQuotationListQuery request, CancellationToken cancellationToken) + { + return await _context.SalesQuotation + .AsNoTracking() + .Include(x => x.Customer) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetSalesQuotationListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + QuotationDate = x.QuotationDate, + CustomerName = x.Customer!.Name, + AfterTaxAmount = x.AfterTaxAmount, + QuotationStatus = x.QuotationStatus + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationLookupHandler.cs b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationLookupHandler.cs new file mode 100644 index 0000000..e95f5f4 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/GetSalesQuotationLookupHandler.cs @@ -0,0 +1,77 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class SalesQuotationLookupResponse +{ + 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 GetSalesQuotationLookupQuery() : IRequest; + +public class GetSalesQuotationLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesQuotationLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesQuotationLookupQuery request, CancellationToken cancellationToken) + { + var response = new SalesQuotationLookupResponse(); + + 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(SalesQuotationStatus)) + .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/SalesQuotation/Cqrs/UpdateSalesQuotationHandler.cs b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationHandler.cs new file mode 100644 index 0000000..8b5ec98 --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class UpdateSalesQuotationRequest +{ + public string? Id { get; set; } + public DateTime? QuotationDate { get; set; } + public Data.Enums.SalesQuotationStatus QuotationStatus { 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 UpdateSalesQuotationCommand(UpdateSalesQuotationRequest Data) : IRequest; + +public class UpdateSalesQuotationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesQuotationHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesQuotationCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesQuotation + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.QuotationDate = request.Data.QuotationDate; + entity.QuotationStatus = request.Data.QuotationStatus; + entity.Description = request.Data.Description; + entity.CustomerId = request.Data.CustomerId; + entity.TaxId = request.Data.TaxId; + + await _context.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrEmpty(entity.Id)) + { + SalesQuotationHelper.Recalculate(_context, entity.Id); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationItemHandler.cs b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationItemHandler.cs new file mode 100644 index 0000000..57a19fb --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationItemHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class UpdateSalesQuotationItemRequest +{ + 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 UpdateSalesQuotationItemCommand(UpdateSalesQuotationItemRequest Data) : IRequest; + +public class UpdateSalesQuotationItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesQuotationItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesQuotationItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesQuotationItem + .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.SalesQuotationId)) + { + SalesQuotationHelper.Recalculate(_context, entity.SalesQuotationId); + } + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationValidator.cs b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationValidator.cs new file mode 100644 index 0000000..f81353f --- /dev/null +++ b/Features/Sales/SalesQuotation/Cqrs/UpdateSalesQuotationValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesQuotation.Cqrs; + +public class UpdateSalesQuotationValidator : AbstractValidator +{ + public UpdateSalesQuotationValidator() + { + 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/SalesQuotation/SalesQuotationEndpoint.cs b/Features/Sales/SalesQuotation/SalesQuotationEndpoint.cs new file mode 100644 index 0000000..a2eddda --- /dev/null +++ b/Features/Sales/SalesQuotation/SalesQuotationEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.SalesQuotation.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.SalesQuotation; + +public static class SalesQuotationEndpoint +{ + public static void MapSalesQuotationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-quotation").WithTags("SalesQuotations") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesQuotationListQuery()); + return result.ToApiResponse("Sales quotation list retrieved successfully"); + }) + .WithName("GetSalesQuotationList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesQuotationByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Sales quotation detail retrieved successfully" + : $"Sales quotation with ID {id} not found"); + }) + .WithName("GetSalesQuotationById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesQuotationLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetSalesQuotationLookup"); + + group.MapPost("/", async (CreateSalesQuotationRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesQuotationCommand(request)); + return result.ToApiResponse("Sales quotation has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSalesQuotation"); + + group.MapPost("/update", async (UpdateSalesQuotationRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesQuotationCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Sales quotation not found."); + } + return result.ToApiResponse("Sales quotation has been updated successfully"); + }) + .WithName("UpdateSalesQuotation"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesQuotationByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Sales quotation not found."); + } + return true.ToApiResponse("Sales quotation has been deleted successfully"); + }) + .WithName("DeleteSalesQuotationById"); + + group.MapPost("/sales-quotation-item", async (CreateSalesQuotationItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesQuotationItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateSalesQuotationItem"); + + group.MapPost("/sales-quotation-item/update", async (UpdateSalesQuotationItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesQuotationItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateSalesQuotationItem"); + + group.MapPost("/sales-quotation-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesQuotationItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteSalesQuotationItem"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesQuotation/SalesQuotationPdfGenerator.cs b/Features/Sales/SalesQuotation/SalesQuotationPdfGenerator.cs new file mode 100644 index 0000000..dbb7465 --- /dev/null +++ b/Features/Sales/SalesQuotation/SalesQuotationPdfGenerator.cs @@ -0,0 +1,152 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.SalesQuotation.Cqrs; + +namespace Indotalent.Features.Sales.SalesQuotation; + +public class SalesQuotationPdfGenerator +{ + public static byte[] Generate(GetSalesQuotationByIdResponse 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 QUOTATION") + .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.QuotationDate?.ToString("dd MMM yyyy")}").FontSize(9); + c.Item().Text($"Status: {data.QuotationStatus.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/SalesQuotation/SalesQuotationService.cs b/Features/Sales/SalesQuotation/SalesQuotationService.cs new file mode 100644 index 0000000..4d4a352 --- /dev/null +++ b/Features/Sales/SalesQuotation/SalesQuotationService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.SalesQuotation.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.SalesQuotation; + +public class SalesQuotationService : BaseService +{ + private readonly RestClient _client; + + public SalesQuotationService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesQuotationListAsync() + { + var request = new RestRequest("api/sales-quotation", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSalesQuotationByIdAsync(string id) + { + var request = new RestRequest($"api/sales-quotation/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesQuotationLookupAsync() + { + var request = new RestRequest("api/sales-quotation/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesQuotationAsync(CreateSalesQuotationRequest data) + { + var request = new RestRequest("api/sales-quotation", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesQuotationAsync(UpdateSalesQuotationRequest data) + { + var request = new RestRequest("api/sales-quotation/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesQuotationByIdAsync(string id) + { + var request = new RestRequest($"api/sales-quotation/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateSalesQuotationItemAsync(CreateSalesQuotationItemRequest data) + { + var request = new RestRequest("api/sales-quotation/sales-quotation-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesQuotationItemAsync(UpdateSalesQuotationItemRequest data) + { + var request = new RestRequest("api/sales-quotation/sales-quotation-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesQuotationItemAsync(string id) + { + var request = new RestRequest($"api/sales-quotation/sales-quotation-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/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..c5ba8a3 --- /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/Sales/SalesReturn/Components/SalesReturnPage.razor b/Features/Sales/SalesReturn/Components/SalesReturnPage.razor new file mode 100644 index 0000000..74326b2 --- /dev/null +++ b/Features/Sales/SalesReturn/Components/SalesReturnPage.razor @@ -0,0 +1,51 @@ +@page "/sales/sales-return" +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using Indotalent.Features.Sales.SalesReturn.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SalesReturnCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SalesReturnUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_SalesReturnDataTable 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 UpdateSalesReturnRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateSalesReturnRequest 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/SalesReturn/Components/_SalesReturnCreateForm.razor b/Features/Sales/SalesReturn/Components/_SalesReturnCreateForm.razor new file mode 100644 index 0000000..caf751d --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnCreateForm.razor @@ -0,0 +1,129 @@ +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SalesReturnService SalesReturnService +@inject ISnackbar Snackbar + + +
+ +
+ Add Sales Return + Process a new return from a delivered order. +
+
+
+ + + + + Basic Information + + + Return Date + + + + + Delivery Order Reference + + @foreach (var item in _lookup.DeliveryOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Reason of Return + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Sales Return + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateSalesReturnValidator _validator = new(); + private CreateSalesReturnRequest _model = new() { ReturnDate = DateTime.Today }; + private SalesReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesReturnService.GetSalesReturnLookupAsync(); + 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 SalesReturnService.CreateSalesReturnAsync(_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/SalesReturn/Components/_SalesReturnDataTable.razor b/Features/Sales/SalesReturn/Components/_SalesReturnDataTable.razor new file mode 100644 index 0000000..185c132 --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnDataTable.razor @@ -0,0 +1,367 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesReturn +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesReturnService SalesReturnService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Return + Manage customer returns and inventory restocking. +
+
+ + / + Sales + / + Sales Return +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add Sales Return + + } +
+
+ + + + + + Number + + + Date + + + DO Number + + + Status + + + + + + + @context.AutoNumber + @context.ReturnDate?.ToString("yyyy-MM-dd") + @context.DeliveryOrderNumber + + @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 GetSalesReturnListResponse? _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 SalesReturnService.GetSalesReturnListAsync(); + 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.DeliveryOrderNumber?.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("SalesReturns"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Number"; + worksheet.Cell(currentRow, 2).Value = "Date"; + worksheet.Cell(currentRow, 3).Value = "DO Reference"; + 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.AutoNumber; + worksheet.Cell(currentRow, 2).Value = item.ReturnDate?.ToString("yyyy-MM-dd"); + worksheet.Cell(currentRow, 3).Value = item.DeliveryOrderNumber; + worksheet.Cell(currentRow, 4).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", "SalesReturn_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 SalesReturnService.GetSalesReturnByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateSalesReturnRequest + { + Id = detail.Id, + ReturnDate = detail.ReturnDate, + Status = detail.Status, + Description = detail.Description, + DeliveryOrderId = detail.DeliveryOrderId, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy, + AutoNumber = detail.AutoNumber + }; + } + 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 SalesReturnService.DeleteSalesReturnByIdAsync(_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/SalesReturn/Components/_SalesReturnItemCreateForm.razor b/Features/Sales/SalesReturn/Components/_SalesReturnItemCreateForm.razor new file mode 100644 index 0000000..30d13f2 --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnItemCreateForm.razor @@ -0,0 +1,89 @@ +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using MudBlazor +@inject SalesReturnService SalesReturnService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Restock to Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Return Quantity + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add Line + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string SalesReturnId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateSalesReturnItemRequest _model = new() { Movement = 1 }; + private SalesReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesReturnService.GetSalesReturnLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + _model.SalesReturnId = SalesReturnId; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesReturnService.CreateSalesReturnItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Components/_SalesReturnItemDataTable.razor b/Features/Sales/SalesReturn/Components/_SalesReturnItemDataTable.razor new file mode 100644 index 0000000..d7f0e67 --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnItemDataTable.razor @@ -0,0 +1,90 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesReturn +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using MudBlazor +@using Indotalent.Features.Root.Shared +@inject SalesReturnService SalesReturnService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Return Items List + @if (!ReadOnly) + { + + Add Line Item + + } +
+ + + + Product + Warehouse + Quantity + @if (!ReadOnly) + { + Actions + } + + + @context.ProductName + @context.WarehouseName + @context.Movement + @if (!ReadOnly) + { + + + + + } + + +
+ + + + +@code { + [Parameter] public string SalesReturnId { 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 { ["SalesReturnId"] = SalesReturnId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesReturnItemCreateForm>("Add Item Return", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(SalesReturnInvenTransResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_SalesReturnItemUpdateForm>("Edit Item Return", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(SalesReturnInvenTransResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this item from the return?" }; + 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 SalesReturnService.DeleteSalesReturnItemAsync(item.Id!); + if (success) + { + Snackbar.Add("Removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Components/_SalesReturnItemUpdateForm.razor b/Features/Sales/SalesReturn/Components/_SalesReturnItemUpdateForm.razor new file mode 100644 index 0000000..af06a7e --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnItemUpdateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using MudBlazor +@inject SalesReturnService SalesReturnService +@inject ISnackbar Snackbar + + + + + + + Product + + @foreach (var item in _lookup.Products) + { + @item.Name + } + + + + Restock to Warehouse + + @foreach (var item in _lookup.Warehouses) + { + @item.Name + } + + + + Return Quantity + + + + + + + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public SalesReturnInvenTransResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateSalesReturnItemRequest _model = new(); + private SalesReturnLookupResponse _lookup = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var res = await SalesReturnService.GetSalesReturnLookupAsync(); + if (res != null && res.IsSuccess) _lookup = res.Value ?? new(); + + _model.Id = Data.Id; + _model.ProductId = Data.ProductId; + _model.WarehouseId = Data.WarehouseId; + _model.Movement = Data.Movement; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await SalesReturnService.UpdateSalesReturnItemAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Line updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Components/_SalesReturnUpdateForm.razor b/Features/Sales/SalesReturn/Components/_SalesReturnUpdateForm.razor new file mode 100644 index 0000000..4c84f1a --- /dev/null +++ b/Features/Sales/SalesReturn/Components/_SalesReturnUpdateForm.razor @@ -0,0 +1,263 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesReturn.Cqrs +@using Indotalent.Features.Sales.SalesReturn.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@using Microsoft.JSInterop +@inject SalesReturnService SalesReturnService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ @(ReadOnly ? "Details" : "Edit") Sales Return + @_model.AutoNumber +
+
+ @if (ReadOnly || !string.IsNullOrEmpty(_model.Id)) + { + + @if (_isPrinting) + { + + Generating PDF... + } + else + { + Print PDF + } + + } +
+ + + + + Basic Information + + + Return Date + + + + + Delivery Order Reference + + @foreach (var item in _lookup.DeliveryOrders) + { + @item.Name + } + + + + + Status + + @foreach (var item in _lookup.Statuses) + { + @item.Name + } + + + + + Description / Reason of Return + + + + + <_SalesReturnItemDataTable Items="_items" SalesReturnId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshItems" /> + + + + + + Movement Summary +
+ Total Items + @_items.Count +
+
+ Total Movement Quantity + @_items.Sum(x => x.Movement) +
+ +
+ Status + @_model.Status.ToString().ToUpper() +
+
+
+ + + 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 UpdateSalesReturnRequest 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 UpdateSalesReturnValidator _validator = new(); + private UpdateSalesReturnRequest _model = new(); + private List _items = new(); + private SalesReturnLookupResponse _lookup = new(); + private bool _processing = false; + private bool _isPrinting = false; + + protected override async Task OnInitializedAsync() + { + var resLookup = await SalesReturnService.GetSalesReturnLookupAsync(); + if (resLookup != null && resLookup.IsSuccess) _lookup = resLookup.Value ?? new(); + + _model = Data; + await RefreshItems(); + } + + private async Task RefreshItems() + { + try + { + var response = await SalesReturnService.GetSalesReturnByIdAsync(_model.Id!); + await Task.Delay(500); + + if (response != null && response.IsSuccess && response.Value != null) + { + _items = response.Value.Items ?? new(); + 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 SalesReturnService.GetSalesReturnByIdAsync(_model.Id!); + if (response != null && response.IsSuccess && response.Value != null) + { + var pdfBytes = SalesReturnPdfGenerator.Generate(response.Value); + var fileName = $"SalesReturn_{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() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var response = await SalesReturnService.UpdateSalesReturnAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + 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/SalesReturn/Cqrs/CreateSalesReturnHandler.cs b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnHandler.cs new file mode 100644 index 0000000..8d0eb8b --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class CreateSalesReturnRequest +{ + public DateTime? ReturnDate { get; set; } + public Data.Enums.SalesReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? DeliveryOrderId { get; set; } +} + +public class CreateSalesReturnResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateSalesReturnCommand(CreateSalesReturnRequest Data) : IRequest; + +public class CreateSalesReturnHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesReturnHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesReturnCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.SalesReturn); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.SalesReturn + { + AutoNumber = autoNo, + ReturnDate = request.Data.ReturnDate, + Status = request.Data.Status, + Description = request.Data.Description, + DeliveryOrderId = request.Data.DeliveryOrderId + }; + + _context.SalesReturn.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateSalesReturnResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnItemHandler.cs b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnItemHandler.cs new file mode 100644 index 0000000..8785f45 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnItemHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class CreateSalesReturnItemRequest +{ + public string? SalesReturnId { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record CreateSalesReturnItemCommand(CreateSalesReturnItemRequest Data) : IRequest; + +public class CreateSalesReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateSalesReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateSalesReturnItemCommand request, CancellationToken cancellationToken) + { + var parent = await _context.SalesReturn + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Data.SalesReturnId, cancellationToken); + + if (parent == null) return false; + + var ivtEntityName = nameof(Data.Entities.InventoryTransaction); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.InventoryTransaction + { + AutoNumber = autoNo, + ModuleId = parent.Id, + ModuleName = nameof(Data.Entities.SalesReturn), + ModuleCode = "SRN", + ModuleNumber = parent.AutoNumber, + MovementDate = parent.ReturnDate ?? DateTime.Now, + Status = (Data.Enums.InventoryTransactionStatus)parent.Status, + WarehouseId = request.Data.WarehouseId, + ProductId = request.Data.ProductId, + Movement = request.Data.Movement + }; + + _context.CalculateInvenTrans(entity); + _context.InventoryTransaction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnValidator.cs b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnValidator.cs new file mode 100644 index 0000000..6748ee1 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/CreateSalesReturnValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class CreateSalesReturnValidator : AbstractValidator +{ + public CreateSalesReturnValidator() + { + RuleFor(x => x.ReturnDate).NotEmpty().WithMessage("Return Date is required"); + RuleFor(x => x.DeliveryOrderId).NotEmpty().WithMessage("Delivery Order reference is required"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnByIdHandler.cs b/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnByIdHandler.cs new file mode 100644 index 0000000..d33df4a --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnByIdHandler.cs @@ -0,0 +1,31 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public record DeleteSalesReturnByIdCommand(string Id) : IRequest; + +public class DeleteSalesReturnByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesReturnByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesReturnByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesReturn + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.SalesReturn)) + .ToListAsync(cancellationToken); + + _context.InventoryTransaction.RemoveRange(transactions); + _context.SalesReturn.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnItemHandler.cs b/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnItemHandler.cs new file mode 100644 index 0000000..2046b77 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/DeleteSalesReturnItemHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public record DeleteSalesReturnItemCommand(string Id) : IRequest; + +public class DeleteSalesReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DeleteSalesReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteSalesReturnItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (entity == null) return false; + + _context.InventoryTransaction.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/GetSalesReturnByIdHandler.cs b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnByIdHandler.cs new file mode 100644 index 0000000..e938b2b --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class SalesReturnInvenTransResponse +{ + public string? Id { get; set; } + public string? ProductId { get; set; } + public string? ProductName { get; set; } + public string? WarehouseId { get; set; } + public string? WarehouseName { get; set; } + public double? Movement { get; set; } +} + +public class GetSalesReturnByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public Data.Enums.SalesReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? DeliveryOrderId { get; set; } + public string? DeliveryOrderNumber { 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 GetSalesReturnByIdQuery(string Id) : IRequest; + +public class GetSalesReturnByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesReturnByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesReturnByIdQuery request, CancellationToken cancellationToken) + { + var master = await _context.SalesReturn + .AsNoTracking() + .Include(x => x.DeliveryOrder) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (master == null) return null; + + var details = await _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleId == request.Id && x.ModuleName == nameof(Data.Entities.SalesReturn)) + .Select(x => new SalesReturnInvenTransResponse + { + Id = x.Id, + ProductId = x.ProductId, + ProductName = x.Product!.Name, + WarehouseId = x.WarehouseId, + WarehouseName = x.Warehouse!.Name, + Movement = x.Movement + }) + .ToListAsync(cancellationToken); + + return new GetSalesReturnByIdResponse + { + Id = master.Id, + AutoNumber = master.AutoNumber, + ReturnDate = master.ReturnDate, + Status = master.Status, + Description = master.Description, + DeliveryOrderId = master.DeliveryOrderId, + DeliveryOrderNumber = master.DeliveryOrder?.AutoNumber, + CreatedAt = master.CreatedAt, + CreatedBy = master.CreatedBy, + UpdatedAt = master.UpdatedAt, + UpdatedBy = master.UpdatedBy, + Items = details + }; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/GetSalesReturnListHandler.cs b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnListHandler.cs new file mode 100644 index 0000000..1a43d11 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnListHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class GetSalesReturnListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public string? DeliveryOrderNumber { get; set; } + public Data.Enums.SalesReturnStatus Status { get; set; } +} + +public record GetSalesReturnListQuery() : IRequest>; + +public class GetSalesReturnListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetSalesReturnListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetSalesReturnListQuery request, CancellationToken cancellationToken) + { + return await _context.SalesReturn + .AsNoTracking() + .Include(x => x.DeliveryOrder) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetSalesReturnListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + ReturnDate = x.ReturnDate, + DeliveryOrderNumber = x.DeliveryOrder!.AutoNumber, + Status = x.Status + }).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/GetSalesReturnLookupHandler.cs b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnLookupHandler.cs new file mode 100644 index 0000000..5e62e76 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/GetSalesReturnLookupHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Indotalent.Data.Enums; +using Indotalent.ConfigBackEnd.Extensions; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class SalesReturnLookupResponse +{ + public List DeliveryOrders { get; set; } = new(); + public List Warehouses { 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 GetSalesReturnLookupQuery() : IRequest; + +public class GetSalesReturnLookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetSalesReturnLookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSalesReturnLookupQuery request, CancellationToken cancellationToken) + { + var response = new SalesReturnLookupResponse(); + + response.DeliveryOrders = await _context.DeliveryOrder.AsNoTracking() + .Where(x => x.Status >= DeliveryOrderStatus.Confirmed) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new LookupItem { Id = x.Id, Name = x.AutoNumber }) + .ToListAsync(cancellationToken); + + response.Warehouses = await _context.Warehouse.AsNoTracking() + .Where(x => x.SystemWarehouse == false) + .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(SalesReturnStatus)) + .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/SalesReturn/Cqrs/UpdateSalesReturnHandler.cs b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnHandler.cs new file mode 100644 index 0000000..6a64f89 --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class UpdateSalesReturnRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public DateTime? ReturnDate { get; set; } + public Data.Enums.SalesReturnStatus Status { get; set; } + public string? Description { get; set; } + public string? DeliveryOrderId { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record UpdateSalesReturnCommand(UpdateSalesReturnRequest Data) : IRequest; + +public class UpdateSalesReturnHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesReturnHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesReturnCommand request, CancellationToken cancellationToken) + { + var entity = await _context.SalesReturn + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.ReturnDate = request.Data.ReturnDate; + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.DeliveryOrderId = request.Data.DeliveryOrderId; + + var transactions = await _context.InventoryTransaction + .Where(x => x.ModuleId == entity.Id && x.ModuleName == nameof(Data.Entities.SalesReturn)) + .ToListAsync(cancellationToken); + + foreach (var trans in transactions) + { + trans.MovementDate = entity.ReturnDate ?? DateTime.Now; + trans.ModuleNumber = entity.AutoNumber; + trans.Status = (Data.Enums.InventoryTransactionStatus)entity.Status; + _context.CalculateInvenTrans(trans); + } + + await _context.SaveChangesAsync(cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnItemHandler.cs b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnItemHandler.cs new file mode 100644 index 0000000..347c10c --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnItemHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class UpdateSalesReturnItemRequest +{ + public string? Id { get; set; } + public string? WarehouseId { get; set; } + public string? ProductId { get; set; } + public double? Movement { get; set; } +} + +public record UpdateSalesReturnItemCommand(UpdateSalesReturnItemRequest Data) : IRequest; + +public class UpdateSalesReturnItemHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateSalesReturnItemHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateSalesReturnItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.InventoryTransaction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + entity.WarehouseId = request.Data.WarehouseId; + entity.ProductId = request.Data.ProductId; + entity.Movement = request.Data.Movement; + + _context.CalculateInvenTrans(entity); + await _context.SaveChangesAsync(cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnValidator.cs b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnValidator.cs new file mode 100644 index 0000000..ed39edb --- /dev/null +++ b/Features/Sales/SalesReturn/Cqrs/UpdateSalesReturnValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Indotalent.Features.Sales.SalesReturn.Cqrs; + +public class UpdateSalesReturnValidator : AbstractValidator +{ + public UpdateSalesReturnValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.DeliveryOrderId).NotEmpty(); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/SalesReturnEndpoint.cs b/Features/Sales/SalesReturn/SalesReturnEndpoint.cs new file mode 100644 index 0000000..0b01d4a --- /dev/null +++ b/Features/Sales/SalesReturn/SalesReturnEndpoint.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.SalesReturn.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.SalesReturn; + +public static class SalesReturnEndpoint +{ + public static void MapSalesReturnEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-return").WithTags("SalesReturns") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReturnListQuery()); + return result.ToApiResponse("Sales return list retrieved successfully"); + }) + .WithName("GetSalesReturnList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReturnByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Sales return detail retrieved successfully" + : $"Sales return with ID {id} not found"); + }) + .WithName("GetSalesReturnById"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReturnLookupQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetSalesReturnLookup"); + + group.MapPost("/", async (CreateSalesReturnRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesReturnCommand(request)); + return result.ToApiResponse("Sales return has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSalesReturn"); + + group.MapPost("/update", async (UpdateSalesReturnRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesReturnCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update failed. Sales return not found."); + } + return result.ToApiResponse("Sales return has been updated successfully"); + }) + .WithName("UpdateSalesReturn"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesReturnByIdCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Sales return not found."); + } + return true.ToApiResponse("Sales return has been deleted successfully"); + }) + .WithName("DeleteSalesReturnById"); + + group.MapPost("/sales-return-item", async (CreateSalesReturnItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSalesReturnItemCommand(request)); + return result.ToApiResponse("Item has been added successfully"); + }) + .WithName("CreateSalesReturnItem"); + + group.MapPost("/sales-return-item/update", async (UpdateSalesReturnItemRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSalesReturnItemCommand(request)); + if (!result) + { + return ((object?)null).ToApiResponse("Update item failed."); + } + return result.ToApiResponse("Item has been updated successfully"); + }) + .WithName("UpdateSalesReturnItem"); + + group.MapPost("/sales-return-item/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSalesReturnItemCommand(id)); + if (!result) + { + return ((object?)null).ToApiResponse("Delete item failed."); + } + return true.ToApiResponse("Item has been removed successfully"); + }) + .WithName("DeleteSalesReturnItem"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturn/SalesReturnPdfGenerator.cs b/Features/Sales/SalesReturn/SalesReturnPdfGenerator.cs new file mode 100644 index 0000000..64ebe9a --- /dev/null +++ b/Features/Sales/SalesReturn/SalesReturnPdfGenerator.cs @@ -0,0 +1,110 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Sales.SalesReturn.Cqrs; + +namespace Indotalent.Features.Sales.SalesReturn; + +public class SalesReturnPdfGenerator +{ + public static byte[] Generate(GetSalesReturnByIdResponse 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 RETURN") + .FontSize(20).ExtraBold().FontColor(primaryBlue); + col.Item().Text($"{data.AutoNumber}").FontSize(11).SemiBold(); + }); + + row.RelativeItem().AlignRight().Column(col => + { + col.Item().Text("RETURN SLIP").FontSize(11).ExtraBold(); + col.Item().Text($"DO Reference: {data.DeliveryOrderNumber}").FontColor(textSlate); + col.Item().Text($"Date: {data.ReturnDate?.ToString("dd MMM yyyy")}").FontColor(textSlate); + }); + }); + + page.Content().PaddingVertical(20).Column(col => + { + col.Item().PaddingTop(10).Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(30); + columns.RelativeColumn(4); + columns.RelativeColumn(3); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(HeaderStyle).Text("#"); + header.Cell().Element(HeaderStyle).Text("Product Name"); + header.Cell().Element(HeaderStyle).Text("Warehouse"); + header.Cell().Element(HeaderStyle).AlignCenter().Text("Qty"); + + 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).Text(item.ProductName); + table.Cell().Element(CellStyle).Text(item.WarehouseName); + table.Cell().Element(CellStyle).AlignCenter().Text(item.Movement?.ToString()); + + static IContainer CellStyle(IContainer container) + { + return container.BorderBottom(1).BorderColor("#E2E8F0").PaddingVertical(8).PaddingHorizontal(5); + } + } + }); + + col.Item().PaddingTop(20).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($"Status: {data.Status.ToString().ToUpper()}").FontSize(9).Bold().FontColor(primaryBlue); + }); + + row.ConstantItem(150).Column(c => { + c.Item().PaddingTop(10).AlignCenter().Text("Authorized By,").FontSize(8); + c.Item().PaddingTop(40).AlignCenter().Text("____________________").FontSize(8); + c.Item().AlignCenter().Text("( Warehouse Manager )").FontSize(7); + }); + }); + }); + + 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/SalesReturn/SalesReturnService.cs b/Features/Sales/SalesReturn/SalesReturnService.cs new file mode 100644 index 0000000..f816cb4 --- /dev/null +++ b/Features/Sales/SalesReturn/SalesReturnService.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.SalesReturn.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.SalesReturn; + +public class SalesReturnService : BaseService +{ + private readonly RestClient _client; + + public SalesReturnService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesReturnListAsync() + { + var request = new RestRequest("api/sales-return", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSalesReturnByIdAsync(string id) + { + var request = new RestRequest($"api/sales-return/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetSalesReturnLookupAsync() + { + var request = new RestRequest("api/sales-return/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSalesReturnAsync(CreateSalesReturnRequest data) + { + var request = new RestRequest("api/sales-return", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesReturnAsync(UpdateSalesReturnRequest data) + { + var request = new RestRequest("api/sales-return/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesReturnByIdAsync(string id) + { + var request = new RestRequest($"api/sales-return/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> CreateSalesReturnItemAsync(CreateSalesReturnItemRequest data) + { + var request = new RestRequest("api/sales-return/sales-return-item", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSalesReturnItemAsync(UpdateSalesReturnItemRequest data) + { + var request = new RestRequest("api/sales-return/sales-return-item/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSalesReturnItemAsync(string id) + { + var request = new RestRequest($"api/sales-return/sales-return-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/SalesReturnReport/Components/SalesReturnReportPage.razor b/Features/Sales/SalesReturnReport/Components/SalesReturnReportPage.razor new file mode 100644 index 0000000..d471924 --- /dev/null +++ b/Features/Sales/SalesReturnReport/Components/SalesReturnReportPage.razor @@ -0,0 +1,8 @@ +@page "/sales/sales-return-report" +@using Indotalent.Features.Sales.SalesReturnReport.Components +@using MudBlazor + +<_SalesReturnReportDataTable /> + +@code { +} \ No newline at end of file diff --git a/Features/Sales/SalesReturnReport/Components/_SalesReturnReportDataTable.razor b/Features/Sales/SalesReturnReport/Components/_SalesReturnReportDataTable.razor new file mode 100644 index 0000000..8fe7d1e --- /dev/null +++ b/Features/Sales/SalesReturnReport/Components/_SalesReturnReportDataTable.razor @@ -0,0 +1,301 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Sales.SalesReturnReport +@using Indotalent.Features.Sales.SalesReturnReport.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject SalesReturnReportService SalesReturnReportService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Sales Return Report + Comprehensive report for all customer returns and stock reversals. +
+
+ + / + Sales + / + Sales Return Report +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + +
+
+ + + + + Return Order + + + Delivery Order + + + Customer + + + Warehouse + + + Product Number + + + Product Name + + + Qty + + + Return Date + + + + @context.SalesReturnNumber + @context.DeliveryOrderNumber + @context.CustomerName + @context.WarehouseName + @context.ProductNumber + @context.ProductName + @context.Quantity?.ToString("N2") + @context.ReturnDate?.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 SalesReturnReportService.GetSalesReturnReportListAsync(); + 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.SalesReturnNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CustomerName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ProductName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.DeliveryOrderNumber?.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("SalesReturnReports"); + var currentRow = 1; + + string[] headers = { "Return Order", "Delivery Order", "Customer", "Warehouse", "Product Number", "Product Name", "Quantity", "Return 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.SalesReturnNumber; + worksheet.Cell(currentRow, 2).Value = item.DeliveryOrderNumber; + worksheet.Cell(currentRow, 3).Value = item.CustomerName; + worksheet.Cell(currentRow, 4).Value = item.WarehouseName; + worksheet.Cell(currentRow, 5).Value = item.ProductNumber; + worksheet.Cell(currentRow, 6).Value = item.ProductName; + worksheet.Cell(currentRow, 7).Value = item.Quantity; + worksheet.Cell(currentRow, 8).Value = item.ReturnDate?.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_Return_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/SalesReturnReport/Cqrs/GetSalesReturnReportListHandler.cs b/Features/Sales/SalesReturnReport/Cqrs/GetSalesReturnReportListHandler.cs new file mode 100644 index 0000000..c435a62 --- /dev/null +++ b/Features/Sales/SalesReturnReport/Cqrs/GetSalesReturnReportListHandler.cs @@ -0,0 +1,66 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Sales.SalesReturnReport.Cqrs; + +public record GetSalesReturnReportListDto +{ + public string? Id { get; init; } + public string? SalesReturnNumber { get; init; } + public string? DeliveryOrderNumber { get; init; } + public string? CustomerName { get; init; } + public string? WarehouseName { get; init; } + public string? ProductNumber { get; init; } + public string? ProductName { get; init; } + public double? Quantity { get; init; } + public DateTime? ReturnDate { get; init; } +} + +public record GetSalesReturnReportListQuery() : IRequest>; + +public class GetSalesReturnReportListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSalesReturnReportListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetSalesReturnReportListQuery request, CancellationToken cancellationToken) + { + var query = _context.InventoryTransaction + .AsNoTracking() + .Include(x => x.Product) + .Include(x => x.Warehouse) + .Where(x => x.ModuleName == "SalesReturn") + .GroupJoin( + _context.SalesReturn.AsNoTracking() + .Include(x => x.DeliveryOrder) + .ThenInclude(x => x!.SalesOrder) + .ThenInclude(x => x!.Customer), + inventory => inventory.ModuleId, + salesReturn => salesReturn.Id, + (inventory, returns) => new { inventory, returns } + ) + .SelectMany( + x => x.returns.DefaultIfEmpty(), + (x, salesReturn) => new GetSalesReturnReportListDto + { + Id = x.inventory.Id, + SalesReturnNumber = salesReturn != null ? salesReturn.AutoNumber : x.inventory.ModuleNumber, + DeliveryOrderNumber = (salesReturn != null && salesReturn.DeliveryOrder != null) ? salesReturn.DeliveryOrder.AutoNumber : string.Empty, + CustomerName = (salesReturn != null && salesReturn.DeliveryOrder != null && salesReturn.DeliveryOrder.SalesOrder != null && salesReturn.DeliveryOrder.SalesOrder.Customer != null) ? salesReturn.DeliveryOrder.SalesOrder.Customer.Name : string.Empty, + WarehouseName = x.inventory.Warehouse != null ? x.inventory.Warehouse.Name : string.Empty, + ProductNumber = x.inventory.Product != null ? x.inventory.Product.AutoNumber : string.Empty, + ProductName = x.inventory.Product != null ? x.inventory.Product.Name : string.Empty, + Quantity = x.inventory.Movement, + ReturnDate = salesReturn != null ? salesReturn.ReturnDate : x.inventory.MovementDate + } + ) + .OrderByDescending(x => x.ReturnDate); + + return await query.ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturnReport/SalesReturnReportEndpoint.cs b/Features/Sales/SalesReturnReport/SalesReturnReportEndpoint.cs new file mode 100644 index 0000000..9d72e16 --- /dev/null +++ b/Features/Sales/SalesReturnReport/SalesReturnReportEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Sales.SalesReturnReport.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Sales.SalesReturnReport; + +public static class SalesReturnReportEndpoint +{ + public static void MapSalesReturnReportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/sales-return-report").WithTags("SalesReturnReports") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSalesReturnReportListQuery()); + return result.ToApiResponse("Sales return report list retrieved successfully"); + }) + .WithName("GetSalesReturnReportList"); + } +} \ No newline at end of file diff --git a/Features/Sales/SalesReturnReport/SalesReturnReportService.cs b/Features/Sales/SalesReturnReport/SalesReturnReportService.cs new file mode 100644 index 0000000..b8ad14b --- /dev/null +++ b/Features/Sales/SalesReturnReport/SalesReturnReportService.cs @@ -0,0 +1,32 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Sales.SalesReturnReport.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Sales.SalesReturnReport; + +public class SalesReturnReportService : BaseService +{ + private readonly RestClient _client; + + public SalesReturnReportService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSalesReturnReportListAsync() + { + var request = new RestRequest("api/sales-return-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..f6f4151 --- /dev/null +++ b/Features/Serilogs/Database/Components/DatabasePage.razor @@ -0,0 +1,407 @@ +@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..8a999fc --- /dev/null +++ b/Features/Serilogs/File/Components/FilePage.razor @@ -0,0 +1,352 @@ +@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..4949e69 --- /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..41f9da6 --- /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..727dbaa --- /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..1db55fb --- /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..1ec3f7a --- /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..a2506f3 --- /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..47c2500 --- /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..2a06b15 --- /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..6b3d35d --- /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..2ef98f0 --- /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..6907c28 --- /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..c8361b2 --- /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..48636b7 --- /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..9f65dd8 --- /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..ba6cddb --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor @@ -0,0 +1,466 @@ +@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..f00f439 --- /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..08c67b6 --- /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..159ee55 --- /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..6505177 --- /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..e8d39ae --- /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..ce5ef03 --- /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..b47d651 --- /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..42ae508 --- /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..97b29ed --- /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..61e2221 --- /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..c1dd53f --- /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..3db42e9 --- /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..ea45f05 --- /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..1ad50a0 --- /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..2d25175 --- /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..2350824 --- /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..10b3ca9 --- /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..85ef3cf --- /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..17eb544 --- /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..615111a --- /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..2bda864 --- /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..8d8a809 --- /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..9f72f41 --- /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..ec79c26 --- /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..94ef518 --- /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..e1d64fc --- /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..9a7e750 --- /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..5da2851 --- /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..855f7be --- /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..e8193eb --- /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..0b49db4 --- /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..866e904 --- /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..32828df --- /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..8a31169 --- /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..a3e0b55 --- /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..f1ffa0f --- /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..13aecf6 --- /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..9990b75 --- /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..c672c09 --- /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..c7fca21 --- /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..8e0022e --- /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..590dfb8 --- /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..7f97f68 --- /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..3de17c0 --- /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..3c7320f --- /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..046f30e --- /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..8ea316e --- /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..48cb332 --- /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..b673b01 --- /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..a7892f2 --- /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..30d16ee --- /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..8d2a599 --- /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..fd6aa07 --- /dev/null +++ b/Infrastructure/Database/AppDbContext.cs @@ -0,0 +1,224 @@ +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 Campaign { get; set; } = default!; + public DbSet Lead { get; set; } = default!; + public DbSet LeadContact { get; set; } = default!; + public DbSet LeadActivity { get; set; } = default!; + public DbSet Budget { get; set; } = default!; + public DbSet Expense { get; set; } = default!; + public DbSet GoodsReceive { get; set; } = default!; + public DbSet Bill { get; set; } = default!; + public DbSet PaymentDisburse { get; set; } = default!; + public DbSet DeliveryOrder { get; set; } = default!; + public DbSet Invoice { get; set; } = default!; + public DbSet PaymentReceive { get; set; } = default!; + public DbSet DebitNote { get; set; } = default!; + public DbSet CreditNote { get; set; } = default!; + public DbSet PositiveAdjustment { get; set; } = default!; + public DbSet NegativeAdjustment { 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 PurchaseRequisition { get; set; } = default!; + public DbSet PurchaseRequisitionItem { get; set; } = default!; + public DbSet PurchaseReturn { 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 SalesQuotation { get; set; } = default!; + public DbSet SalesQuotationItem { get; set; } = default!; + public DbSet SalesRepresentative { get; set; } = default!; + public DbSet SalesReturn { get; set; } = default!; + public DbSet SalesTeam { get; set; } = default!; + public DbSet Scrapping { get; set; } = default!; + public DbSet StockCount { get; set; } = default!; + public DbSet Todo { get; set; } = default!; + public DbSet TodoItem { get; set; } = default!; + public DbSet TransferIn { get; set; } = default!; + public DbSet TransferOut { 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..afc92d4 --- /dev/null +++ b/Infrastructure/Database/AppDbContextExtensions.cs @@ -0,0 +1,32 @@ +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 RecalculatePurchaseRequisition(this AppDbContext context, string requisitionId) + { + PurchaseRequisitionHelper.Recalculate(context, requisitionId); + } + public static void RecalculateSalesOrder(this AppDbContext context, string salesOrderId) + { + SalesOrderHelper.Recalculate(context, salesOrderId); + } + public static void RecalculateSalesQuotation(this AppDbContext context, string quotationId) + { + SalesQuotationHelper.Recalculate(context, quotationId); + } +} 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..3dde8c9 --- /dev/null +++ b/Infrastructure/Database/DatabaseSeederDemo.cs @@ -0,0 +1,81 @@ +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 & CRM Related + await SalesTeamSeeder.GenerateDataAsync(context); + await SalesRepresentativeSeeder.GenerateDataAsync(context); + await CampaignSeeder.GenerateDataAsync(context); + await LeadSeeder.GenerateDataAsync(context); + await LeadContactSeeder.GenerateDataAsync(context); + await LeadActivitySeeder.GenerateDataAsync(context); + await SalesQuotationSeeder.GenerateDataAsync(context); + await SalesOrderSeeder.GenerateDataAsync(context); + + // 5. Purchase Related + await PurchaseRequisitionSeeder.GenerateDataAsync(context); + await PurchaseOrderSeeder.GenerateDataAsync(context); + + // 6. Inventory Operations + await DeliveryOrderSeeder.GenerateDataAsync(context); + await SalesReturnSeeder.GenerateDataAsync(context); + await GoodsReceiveSeeder.GenerateDataAsync(context); + await PurchaseReturnSeeder.GenerateDataAsync(context); + await TransferOutSeeder.GenerateDataAsync(context); + await TransferInSeeder.GenerateDataAsync(context); + await PositiveAdjustmentSeeder.GenerateDataAsync(context); + await NegativeAdjustmentSeeder.GenerateDataAsync(context); + await ScrappingSeeder.GenerateDataAsync(context); + await StockCountSeeder.GenerateDataAsync(context); + + // 7. Finance & Project Related + await InvoiceSeeder.GenerateDataAsync(context); + await CreditNoteSeeder.GenerateDataAsync(context); + await PaymentReceiveSeeder.GenerateDataAsync(context); + await BillSeeder.GenerateDataAsync(context); + await DebitNoteSeeder.GenerateDataAsync(context); + await PaymentDisburseSeeder.GenerateDataAsync(context); + await BudgetSeeder.GenerateDataAsync(context); + await ExpenseSeeder.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/BudgetSeeder.cs b/Infrastructure/Database/Demo/BudgetSeeder.cs new file mode 100644 index 0000000..79cabd9 --- /dev/null +++ b/Infrastructure/Database/Demo/BudgetSeeder.cs @@ -0,0 +1,96 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class BudgetSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Budget.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Budget); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var targetCampaigns = await context.Campaign + .Where(c => c.Status == CampaignStatus.Confirmed || + c.Status == CampaignStatus.OnProgress || + c.Status == CampaignStatus.Finished) + .Select(c => c.Id) + .ToListAsync(); + + if (!targetCampaigns.Any()) return; + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + var transactionDaysList = GetRandomDays(date.Year, date.Month, 8).ToList(); + + foreach (var transDate in transactionDaysList) + { + var status = GetRandomStatus(random); + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var budget = new Budget + { + AutoNumber = autoNo, + Title = $"Budget for {transDate:MMMM yyyy}", + Description = $"Description for budget on {transDate:MMMM yyyy}", + BudgetDate = transDate, + Status = status, + Amount = (decimal)(10000 * Math.Ceiling((random.NextDouble() * 89) + 1)), + CampaignId = GetRandomValue(targetCampaigns, random) + }; + + await context.Budget.AddAsync(budget); + } + } + + await context.SaveChangesAsync(); + } + + private static BudgetStatus GetRandomStatus(Random random) + { + var statuses = new[] { BudgetStatus.Draft, BudgetStatus.Cancelled, BudgetStatus.Confirmed, BudgetStatus.Archived }; + var weights = new[] { 1, 1, 3, 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 BudgetStatus.Confirmed; + } + + private static string 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/CampaignSeeder.cs b/Infrastructure/Database/Demo/CampaignSeeder.cs new file mode 100644 index 0000000..60581f4 --- /dev/null +++ b/Infrastructure/Database/Demo/CampaignSeeder.cs @@ -0,0 +1,100 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class CampaignSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Campaign.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Campaign); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var salesTeamIds = await context.SalesTeam + .Select(st => st.Id) + .ToListAsync(); + + if (!salesTeamIds.Any()) return; + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] campaignStarts = GetRandomDays(date.Year, date.Month, 3); + + foreach (var campaignStart in campaignStarts) + { + var duration = random.Next(1, 4); + var campaignEnd = campaignStart.AddMonths(duration); + + var status = GetRandomStatus(random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + string firstFourChars = autoNo.Length >= 4 ? autoNo.Substring(0, 4) : autoNo; + + var campaign = new Campaign + { + AutoNumber = autoNo, + Title = $"{firstFourChars} Campaign for {campaignStart:MMMM yyyy}", + Description = $"Description for campaign starting {campaignStart:MMMM yyyy}", + TargetRevenueAmount = (decimal)(10000 * Math.Ceiling((random.NextDouble() * 89) + 1)), + CampaignDateStart = campaignStart, + CampaignDateFinish = campaignEnd, + Status = status, + SalesTeamId = GetRandomValue(salesTeamIds, random) + }; + + await context.Campaign.AddAsync(campaign); + } + } + + await context.SaveChangesAsync(); + } + + private static CampaignStatus GetRandomStatus(Random random) + { + var statuses = new[] { CampaignStatus.Draft, CampaignStatus.Cancelled, CampaignStatus.Confirmed, CampaignStatus.OnProgress, CampaignStatus.OnHold, CampaignStatus.Finished, CampaignStatus.Archived }; + var weights = new[] { 1, 1, 10, 2, 1, 5, 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 CampaignStatus.Confirmed; + } + + private static string 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/CreditNoteSeeder.cs b/Infrastructure/Database/Demo/CreditNoteSeeder.cs new file mode 100644 index 0000000..f4e64f4 --- /dev/null +++ b/Infrastructure/Database/Demo/CreditNoteSeeder.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 CreditNoteSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.CreditNote.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(CreditNote); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedSalesReturns = await context.SalesReturn + .Where(sr => sr.Status == SalesReturnStatus.Confirmed) + .Select(sr => sr.Id) + .ToListAsync(); + + if (!confirmedSalesReturns.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] creditNoteDates = GetRandomDays(date.Year, date.Month, 3); + + foreach (var creditNoteDate in creditNoteDates) + { + if (confirmedSalesReturns.Count == 0) break; + + var status = GetRandomStatus(random); + var salesReturnId = GetRandomAndRemove(confirmedSalesReturns, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var creditNote = new CreditNote + { + AutoNumber = autoNo, + CreditNoteDate = creditNoteDate, + CreditNoteStatus = status, + Description = $"Credit Note for {creditNoteDate:MMMM yyyy}", + SalesReturnId = salesReturnId + }; + + await context.CreditNote.AddAsync(creditNote); + } + } + + await context.SaveChangesAsync(); + } + + private static CreditNoteStatus GetRandomStatus(Random random) + { + var statuses = new[] { CreditNoteStatus.Draft, CreditNoteStatus.Cancelled, CreditNoteStatus.Confirmed, CreditNoteStatus.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 CreditNoteStatus.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/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/DebitNoteSeeder.cs b/Infrastructure/Database/Demo/DebitNoteSeeder.cs new file mode 100644 index 0000000..6cea083 --- /dev/null +++ b/Infrastructure/Database/Demo/DebitNoteSeeder.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 DebitNoteSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.DebitNote.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(DebitNote); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-12).Year, dateFinish.AddMonths(-12).Month, 1); + + var confirmedPurchaseReturns = await context.PurchaseReturn + .Where(pr => pr.Status == PurchaseReturnStatus.Confirmed) + .Select(pr => pr.Id) + .ToListAsync(); + + if (!confirmedPurchaseReturns.Any()) return; + + for (DateTime date = dateStart; date < dateFinish; date = date.AddMonths(1)) + { + DateTime[] debitNoteDates = GetRandomDays(date.Year, date.Month, 3); + + foreach (var debitNoteDate in debitNoteDates) + { + if (confirmedPurchaseReturns.Count == 0) break; + + var status = GetRandomStatus(random); + var purchaseReturnId = GetRandomAndRemove(confirmedPurchaseReturns, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var debitNote = new DebitNote + { + AutoNumber = autoNo, + DebitNoteDate = debitNoteDate, + DebitNoteStatus = status, + Description = $"Debit Note for {debitNoteDate:MMMM yyyy}", + PurchaseReturnId = purchaseReturnId + }; + + await context.DebitNote.AddAsync(debitNote); + } + } + + await context.SaveChangesAsync(); + } + + private static DebitNoteStatus GetRandomStatus(Random random) + { + var statuses = new[] { DebitNoteStatus.Draft, DebitNoteStatus.Cancelled, DebitNoteStatus.Confirmed, DebitNoteStatus.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 DebitNoteStatus.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/DeliveryOrderSeeder.cs b/Infrastructure/Database/Demo/DeliveryOrderSeeder.cs new file mode 100644 index 0000000..27994b9 --- /dev/null +++ b/Infrastructure/Database/Demo/DeliveryOrderSeeder.cs @@ -0,0 +1,86 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; +using Indotalent.Shared.Utils; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class DeliveryOrderSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.DeliveryOrder.AnyAsync()) return; + + var random = new Random(); + var deliveryOrderStatusValues = Enum.GetValues(typeof(DeliveryOrderStatus)).Cast().ToList(); + var doEntityName = nameof(DeliveryOrder); + var ivtEntityName = nameof(InventoryTransaction); + + var salesOrders = await context.SalesOrder + .Where(x => x.OrderStatus >= SalesOrderStatus.Confirmed) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!warehouses.Any()) return; + + foreach (var salesOrder in salesOrders) + { + var autoNoDO = await context.GenerateAutoNumberAsync( + entityName: doEntityName, + prefixTemplate: $"{doEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var deliveryOrder = new DeliveryOrder + { + AutoNumber = autoNoDO, + DeliveryDate = salesOrder.OrderDate?.AddDays(random.Next(1, 5)), + Status = deliveryOrderStatusValues[random.Next(deliveryOrderStatusValues.Count)], + SalesOrderId = salesOrder.Id, + }; + await context.DeliveryOrder.AddAsync(deliveryOrder); + + var items = await context.SalesOrderItem + .Include(x => x.Product) + .Where(x => x.SalesOrderId == salesOrder.Id && x.Product!.Physical == true) + .ToListAsync(); + + foreach (var item in items) + { + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = deliveryOrder.Id, + ModuleName = doEntityName, + ModuleCode = "DO", + ModuleNumber = deliveryOrder.AutoNumber, + MovementDate = deliveryOrder.DeliveryDate!.Value, + Status = (InventoryTransactionStatus)deliveryOrder.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = item.ProductId, + Movement = item.Quantity!.Value + }; + + context.CalculateInvenTrans(inventoryTransaction); + + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + await context.SaveChangesAsync(); + } + } + + 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/ExpenseSeeder.cs b/Infrastructure/Database/Demo/ExpenseSeeder.cs new file mode 100644 index 0000000..bae757d --- /dev/null +++ b/Infrastructure/Database/Demo/ExpenseSeeder.cs @@ -0,0 +1,97 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class ExpenseSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Expense.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Expense); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var targetCampaigns = await context.Campaign + .Where(c => c.Status == CampaignStatus.Confirmed || + c.Status == CampaignStatus.OnProgress || + c.Status == CampaignStatus.Finished) + .Select(c => c.Id) + .ToListAsync(); + + if (!targetCampaigns.Any()) return; + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] expenseDates = GetRandomDays(date.Year, date.Month, 5); + + foreach (var expenseDate in expenseDates) + { + var status = GetRandomStatus(random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var expense = new Expense + { + AutoNumber = autoNo, + Title = $"Expense for {expenseDate:MMMM yyyy}", + Description = $"Description for expense on {expenseDate:MMMM yyyy}", + ExpenseDate = expenseDate, + Status = status, + Amount = (decimal)(1000 * Math.Ceiling((random.NextDouble() * 89) + 1)), + CampaignId = GetRandomValue(targetCampaigns, random) + }; + + await context.Expense.AddAsync(expense); + } + } + + await context.SaveChangesAsync(); + } + + private static ExpenseStatus GetRandomStatus(Random random) + { + var statuses = new[] { ExpenseStatus.Draft, ExpenseStatus.Cancelled, ExpenseStatus.Confirmed, ExpenseStatus.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 ExpenseStatus.Confirmed; + } + + private static string 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/GoodsReceiveSeeder.cs b/Infrastructure/Database/Demo/GoodsReceiveSeeder.cs new file mode 100644 index 0000000..11f9911 --- /dev/null +++ b/Infrastructure/Database/Demo/GoodsReceiveSeeder.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 GoodsReceiveSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.GoodsReceive.AnyAsync()) return; + + var random = new Random(); + var goodsReceiveStatusValues = Enum.GetValues(typeof(GoodsReceiveStatus)).Cast().ToList(); + var grEntityName = nameof(GoodsReceive); + var ivtEntityName = nameof(InventoryTransaction); + + var purchaseOrders = await context.PurchaseOrder + .Where(x => x.OrderStatus >= PurchaseOrderStatus.Confirmed) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!warehouses.Any()) return; + + foreach (var purchaseOrder in purchaseOrders) + { + var autoNoGR = await context.GenerateAutoNumberAsync( + entityName: grEntityName, + prefixTemplate: $"{grEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var goodsReceive = new GoodsReceive + { + AutoNumber = autoNoGR, + ReceiveDate = purchaseOrder.OrderDate?.AddDays(random.Next(1, 5)), + Status = goodsReceiveStatusValues[random.Next(goodsReceiveStatusValues.Count)], + PurchaseOrderId = purchaseOrder.Id, + }; + await context.GoodsReceive.AddAsync(goodsReceive); + + var items = await context.PurchaseOrderItem + .Include(x => x.Product) + .Where(x => x.PurchaseOrderId == purchaseOrder.Id && x.Product!.Physical == true) + .ToListAsync(); + + foreach (var item in items) + { + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = goodsReceive.Id, + ModuleName = grEntityName, + ModuleCode = "GR", + ModuleNumber = goodsReceive.AutoNumber, + MovementDate = goodsReceive.ReceiveDate!.Value, + Status = (InventoryTransactionStatus)goodsReceive.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = item.ProductId, + Movement = item.Quantity!.Value + }; + + context.CalculateInvenTrans(inventoryTransaction); + + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + await context.SaveChangesAsync(); + } + } + + 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/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/LeadActivitySeeder.cs b/Infrastructure/Database/Demo/LeadActivitySeeder.cs new file mode 100644 index 0000000..09cbad8 --- /dev/null +++ b/Infrastructure/Database/Demo/LeadActivitySeeder.cs @@ -0,0 +1,80 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class LeadActivitySeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.LeadActivity.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(LeadActivity); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var leads = await context.Lead.Select(l => l.Id).ToListAsync(); + + if (!leads.Any()) return; + + var activityTypeValues = Enum.GetValues(typeof(LeadActivityType)).Cast().ToList(); + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] activityDates = GetRandomDays(date.Year, date.Month, 10); + + foreach (var activityDate in activityDates) + { + var leadId = GetRandomValue(leads, random); + var fromDate = activityDate; + var toDate = fromDate.AddHours(random.Next(1, 5)); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var leadActivity = new LeadActivity + { + LeadId = leadId, + AutoNumber = autoNo, + Summary = $"Activity on {fromDate:MMMM d, yyyy}", + Description = $"Description for activity on {fromDate:MMMM d, yyyy}", + FromDate = fromDate, + ToDate = toDate, + Type = activityTypeValues[random.Next(activityTypeValues.Count)], + AttachmentName = random.Next(1, 100) % 2 == 0 ? $"file_{random.Next(1, 100)}.pdf" : null + }; + + await context.LeadActivity.AddAsync(leadActivity); + } + } + + await context.SaveChangesAsync(); + } + + private static string 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/LeadContactSeeder.cs b/Infrastructure/Database/Demo/LeadContactSeeder.cs new file mode 100644 index 0000000..3d20850 --- /dev/null +++ b/Infrastructure/Database/Demo/LeadContactSeeder.cs @@ -0,0 +1,87 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class LeadContactSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.LeadContact.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(LeadContact); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var leads = await context.Lead.Select(l => l.Id).ToListAsync(); + + if (!leads.Any()) return; + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] contactDates = GetRandomDays(date.Year, date.Month, 5); + + foreach (var contactDate in contactDates) + { + var leadId = GetRandomValue(leads, random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var leadContact = new LeadContact + { + LeadId = leadId, + AutoNumber = autoNo, + FullName = $"Contact {random.Next(1000, 9999)}", + Description = "Sample contact description", + AddressStreet = "456 Elm St", + AddressCity = "Anytown", + AddressState = "State", + AddressZipCode = "67890", + AddressCountry = "Country", + PhoneNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + FaxNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + MobileNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + Email = $"contact{random.Next(1000, 9999)}@company.com", + Website = $"www.contact{random.Next(1000, 9999)}.com", + WhatsApp = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + LinkedIn = $"linkedin.com/in/contact{random.Next(1000, 9999)}", + Facebook = $"facebook.com/contact{random.Next(1000, 9999)}", + Twitter = $"twitter.com/contact{random.Next(1000, 9999)}", + Instagram = $"instagram.com/contact{random.Next(1000, 9999)}", + AvatarName = $"avatar_{random.Next(1, 100)}.jpg" + }; + + await context.LeadContact.AddAsync(leadContact); + } + } + + await context.SaveChangesAsync(); + } + + private static string 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/LeadSeeder.cs b/Infrastructure/Database/Demo/LeadSeeder.cs new file mode 100644 index 0000000..1bd036f --- /dev/null +++ b/Infrastructure/Database/Demo/LeadSeeder.cs @@ -0,0 +1,110 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class LeadSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Lead.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(Lead); + var dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + var confirmedCampaigns = await context.Campaign + .Where(c => c.Status == CampaignStatus.Confirmed) + .Select(c => c.Id) + .ToListAsync(); + + var salesTeamIds = await context.SalesTeam + .Select(st => st.Id) + .ToListAsync(); + + if (!confirmedCampaigns.Any() || !salesTeamIds.Any()) return; + + var closingStatusValues = Enum.GetValues(typeof(ClosingStatus)).Cast().ToList(); + + var pipelineStageCounts = new Dictionary + { + { PipelineStage.Prospecting, 80 }, + { PipelineStage.Qualification, 70 }, + { PipelineStage.NeedAnalysis, 60 }, + { PipelineStage.Proposal, 50 }, + { PipelineStage.Negotiation, 40 }, + { PipelineStage.DecisionMaking, 30 }, + { PipelineStage.Closed, 15 } + }; + + foreach (var stage in pipelineStageCounts) + { + for (int i = 0; i < stage.Value; i++) + { + var prospectingDate = GetRandomDate(dateStart, dateFinish, random); + var closingEstimation = prospectingDate.AddDays(random.Next(30, 90)); + var closingActual = closingEstimation.AddDays(random.Next(-10, 11)); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var lead = new Lead + { + AutoNumber = autoNo, + Title = $"Lead from {prospectingDate:MMMM yyyy}", + Description = $"Lead description for {prospectingDate:MMMM yyyy}", + CompanyName = $"Company Name {random.Next(1000, 9999)}", + CompanyDescription = "Sample company description", + CompanyAddressStreet = "123 Main St", + CompanyAddressCity = "Anytown", + CompanyAddressState = "State", + CompanyAddressZipCode = "12345", + CompanyAddressCountry = "Country", + CompanyPhoneNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + CompanyFaxNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + CompanyEmail = $"info{random.Next(1000, 9999)}@company.com", + CompanyWebsite = $"www.company{random.Next(1000, 9999)}.com", + CompanyWhatsApp = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + CompanyLinkedIn = $"linkedin.com/company{random.Next(1000, 9999)}", + CompanyFacebook = $"facebook.com/company{random.Next(1000, 9999)}", + CompanyInstagram = $"instagram.com/company{random.Next(1000, 9999)}", + CompanyTwitter = $"twitter.com/company{random.Next(1000, 9999)}", + DateProspecting = prospectingDate, + DateClosingEstimation = closingEstimation, + DateClosingActual = closingActual, + AmountTargeted = (decimal)(10000 * Math.Ceiling((random.NextDouble() * 89) + 1)), + AmountClosed = (decimal)(10000 * Math.Ceiling((random.NextDouble() * 89) + 1)), + BudgetScore = (decimal)(10.0 * Math.Ceiling(random.NextDouble() * 10)), + AuthorityScore = (decimal)(10.0 * Math.Ceiling(random.NextDouble() * 10)), + NeedScore = (decimal)(10.0 * Math.Ceiling(random.NextDouble() * 10)), + TimelineScore = (decimal)(10.0 * Math.Ceiling(random.NextDouble() * 10)), + PipelineStage = stage.Key, + ClosingStatus = closingStatusValues[random.Next(closingStatusValues.Count)], + ClosingNote = "Sample closing note", + CampaignId = GetRandomValue(confirmedCampaigns, random), + SalesTeamId = GetRandomValue(salesTeamIds, random) + }; + + await context.Lead.AddAsync(lead); + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime GetRandomDate(DateTime startDate, DateTime endDate, Random random) + { + var range = (endDate - startDate).Days; + return startDate.AddDays(random.Next(range)); + } + + private static string GetRandomValue(List list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/NegativeAdjustmentSeeder.cs b/Infrastructure/Database/Demo/NegativeAdjustmentSeeder.cs new file mode 100644 index 0000000..4ba3fd0 --- /dev/null +++ b/Infrastructure/Database/Demo/NegativeAdjustmentSeeder.cs @@ -0,0 +1,98 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class NegativeAdjustmentSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.NegativeAdjustment.AnyAsync()) return; + + var random = new Random(); + var adjustmentStatusValues = Enum.GetValues(typeof(AdjustmentStatus)).Cast().ToList(); + var entityName = nameof(NegativeAdjustment); + var ivtEntityName = nameof(InventoryTransaction); + + var products = await context.Product + .Where(x => x.Physical == true) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!products.Any() || !warehouses.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 (var transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var adjustmentMinus = new NegativeAdjustment + { + AutoNumber = autoNo, + AdjustmentDate = transDate, + Status = adjustmentStatusValues[random.Next(adjustmentStatusValues.Count)], + }; + await context.NegativeAdjustment.AddAsync(adjustmentMinus); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = products[random.Next(products.Count)]; + + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = adjustmentMinus.Id, + ModuleName = entityName, + ModuleCode = "ADJ-", + ModuleNumber = adjustmentMinus.AutoNumber, + MovementDate = adjustmentMinus.AdjustmentDate!.Value, + Status = (InventoryTransactionStatus)adjustmentMinus.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = product.Id, + Movement = random.Next(1, 3) + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + var daysInMonth = DateTime.DaysInMonth(year, month); + return Enumerable.Range(1, count) + .Select(_ => new DateTime(year, month, random.Next(1, daysInMonth + 1))) + .ToArray(); + } + + private static T GetRandomValue(IList list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ 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/PositiveAdjustmentSeeder.cs b/Infrastructure/Database/Demo/PositiveAdjustmentSeeder.cs new file mode 100644 index 0000000..3a374ab --- /dev/null +++ b/Infrastructure/Database/Demo/PositiveAdjustmentSeeder.cs @@ -0,0 +1,98 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PositiveAdjustmentSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PositiveAdjustment.AnyAsync()) return; + + var random = new Random(); + var adjustmentStatusValues = Enum.GetValues(typeof(AdjustmentStatus)).Cast().ToList(); + var entityName = nameof(PositiveAdjustment); + var ivtEntityName = nameof(InventoryTransaction); + + var products = await context.Product + .Where(x => x.Physical == true) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!products.Any() || !warehouses.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 (var transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var adjustmentPlus = new PositiveAdjustment + { + AutoNumber = autoNo, + AdjustmentDate = transDate, + Status = adjustmentStatusValues[random.Next(adjustmentStatusValues.Count)], + }; + await context.PositiveAdjustment.AddAsync(adjustmentPlus); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = products[random.Next(products.Count)]; + + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = adjustmentPlus.Id, + ModuleName = entityName, + ModuleCode = "ADJ+", + ModuleNumber = adjustmentPlus.AutoNumber, + MovementDate = adjustmentPlus.AdjustmentDate!.Value, + Status = (InventoryTransactionStatus)adjustmentPlus.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = product.Id, + Movement = random.Next(5, 10) + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + var daysInMonth = DateTime.DaysInMonth(year, month); + return Enumerable.Range(1, count) + .Select(_ => new DateTime(year, month, random.Next(1, daysInMonth + 1))) + .ToArray(); + } + + private static T GetRandomValue(IList list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ 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/PurchaseRequisitionSeeder.cs b/Infrastructure/Database/Demo/PurchaseRequisitionSeeder.cs new file mode 100644 index 0000000..5c3e159 --- /dev/null +++ b/Infrastructure/Database/Demo/PurchaseRequisitionSeeder.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 PurchaseRequisitionSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PurchaseRequisition.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(PurchaseRequisition); + 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 dateFinish = DateTime.Now; + var dateStart = new DateTime(dateFinish.AddMonths(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] requisitionDates = GetRandomDays(date.Year, date.Month, 4); + + foreach (var requisitionDate in requisitionDates) + { + var status = GetRandomStatus(random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var purchaseRequisition = new PurchaseRequisition + { + AutoNumber = autoNo, + RequisitionDate = requisitionDate, + RequisitionStatus = status, + Description = $"Requisition for {requisitionDate:MMMM yyyy}", + VendorId = GetRandomValue(vendors, random), + TaxId = GetRandomValue(taxes, random), + }; + await context.PurchaseRequisition.AddAsync(purchaseRequisition); + + int numberOfItems = random.Next(2, 5); + for (int i = 0; i < numberOfItems; i++) + { + var qty = (double)random.Next(2, 5); + var product = products[random.Next(products.Count)]; + + var purchaseRequisitionItem = new PurchaseRequisitionItem + { + PurchaseRequisitionId = purchaseRequisition.Id, + ProductId = product.Id, + Summary = product.AutoNumber, + UnitPrice = product.UnitPrice, + Quantity = qty, + Total = (product.UnitPrice ?? 0m) * (decimal)qty + }; + await context.PurchaseRequisitionItem.AddAsync(purchaseRequisitionItem); + } + + await context.SaveChangesAsync(); + context.RecalculatePurchaseRequisition(purchaseRequisition.Id); + await context.SaveChangesAsync(); + } + } + } + + private static PurchaseRequisitionStatus GetRandomStatus(Random random) + { + var statuses = new[] { + PurchaseRequisitionStatus.Draft, + PurchaseRequisitionStatus.Cancelled, + PurchaseRequisitionStatus.Confirmed, + PurchaseRequisitionStatus.Ordered + }; + 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 PurchaseRequisitionStatus.Confirmed; + } + + private static string 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/PurchaseReturnSeeder.cs b/Infrastructure/Database/Demo/PurchaseReturnSeeder.cs new file mode 100644 index 0000000..85bcb4a --- /dev/null +++ b/Infrastructure/Database/Demo/PurchaseReturnSeeder.cs @@ -0,0 +1,87 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class PurchaseReturnSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.PurchaseReturn.AnyAsync()) return; + + var random = new Random(); + var purchaseReturnStatusValues = Enum.GetValues(typeof(PurchaseReturnStatus)).Cast().ToList(); + var prnEntityName = nameof(PurchaseReturn); + var grEntityName = nameof(GoodsReceive); + var ivtEntityName = nameof(InventoryTransaction); + + var goodsReceives = await context.GoodsReceive + .Where(x => x.Status >= GoodsReceiveStatus.Confirmed) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!warehouses.Any()) return; + + foreach (var goodsReceive in goodsReceives) + { + bool skip = random.Next(2) == 0; + if (skip) continue; + + var autoNoPRN = await context.GenerateAutoNumberAsync( + entityName: prnEntityName, + prefixTemplate: $"{prnEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var purchaseReturn = new PurchaseReturn + { + AutoNumber = autoNoPRN, + ReturnDate = goodsReceive.ReceiveDate?.AddDays(random.Next(1, 5)), + Status = purchaseReturnStatusValues[random.Next(purchaseReturnStatusValues.Count)], + GoodsReceiveId = goodsReceive.Id, + }; + await context.PurchaseReturn.AddAsync(purchaseReturn); + + var items = await context.InventoryTransaction + .Where(x => x.ModuleId == goodsReceive.Id && x.ModuleName == grEntityName) + .ToListAsync(); + + foreach (var item in items) + { + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = purchaseReturn.Id, + ModuleName = prnEntityName, + ModuleCode = "PRN", + ModuleNumber = purchaseReturn.AutoNumber, + MovementDate = purchaseReturn.ReturnDate!.Value, + Status = (InventoryTransactionStatus)purchaseReturn.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = item.ProductId, + Movement = item.Movement + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + await context.SaveChangesAsync(); + } + } + + 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/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/SalesQuotationSeeder.cs b/Infrastructure/Database/Demo/SalesQuotationSeeder.cs new file mode 100644 index 0000000..966a221 --- /dev/null +++ b/Infrastructure/Database/Demo/SalesQuotationSeeder.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 SalesQuotationSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.SalesQuotation.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(SalesQuotation); + 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(-11).Year, dateFinish.AddMonths(-11).Month, 1); + + for (DateTime date = dateStart; date <= dateFinish; date = date.AddMonths(1)) + { + DateTime[] quotationDates = GetRandomDays(date.Year, date.Month, 4); + + foreach (var quotationDate in quotationDates) + { + var status = GetRandomStatus(random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var salesQuotation = new SalesQuotation + { + AutoNumber = autoNo, + QuotationDate = quotationDate, + QuotationStatus = status, + Description = $"Quotation for {quotationDate:MMMM yyyy}", + CustomerId = GetRandomValue(customers, random), + TaxId = GetRandomValue(taxes, random), + }; + await context.SalesQuotation.AddAsync(salesQuotation); + + int numberOfItems = random.Next(2, 5); + for (int i = 0; i < numberOfItems; i++) + { + var qty = (double)random.Next(2, 5); + var product = products[random.Next(products.Count)]; + + var salesQuotationItem = new SalesQuotationItem + { + SalesQuotationId = salesQuotation.Id, + ProductId = product.Id, + Summary = product.AutoNumber, + UnitPrice = product.UnitPrice, + Quantity = qty, + Total = (product.UnitPrice ?? 0m) * (decimal)qty + }; + await context.SalesQuotationItem.AddAsync(salesQuotationItem); + } + + await context.SaveChangesAsync(); + context.RecalculateSalesQuotation(salesQuotation.Id); + await context.SaveChangesAsync(); + } + } + } + + private static SalesQuotationStatus GetRandomStatus(Random random) + { + var statuses = new[] { + SalesQuotationStatus.Draft, + SalesQuotationStatus.Cancelled, + SalesQuotationStatus.Confirmed, + SalesQuotationStatus.Ordered + }; + 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 SalesQuotationStatus.Confirmed; + } + + private static string 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/SalesRepresentativeSeeder.cs b/Infrastructure/Database/Demo/SalesRepresentativeSeeder.cs new file mode 100644 index 0000000..10aa591 --- /dev/null +++ b/Infrastructure/Database/Demo/SalesRepresentativeSeeder.cs @@ -0,0 +1,44 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class SalesRepresentativeSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.SalesRepresentative.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(SalesRepresentative); + var salesTeams = await context.SalesTeam.ToListAsync(); + + foreach (var team in salesTeams) + { + for (int i = 1; i <= 5; i++) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var salesRep = new SalesRepresentative + { + Name = $"Rep {i} - {team.Name}", + AutoNumber = autoNo, + JobTitle = "Sales " + (i == 1 ? "Manager" : "Representative"), + EmployeeNumber = $"EMP-{random.Next(1000, 9999)}", + PhoneNumber = $"+1{random.Next(100, 999)}-{random.Next(100, 999)}-{random.Next(1000, 9999)}", + EmailAddress = $"salesrep{i}@company.com", + Description = $"Sales Rep for {team.Name}", + SalesTeamId = team.Id + }; + + await context.SalesRepresentative.AddAsync(salesRep); + } + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/SalesReturnSeeder.cs b/Infrastructure/Database/Demo/SalesReturnSeeder.cs new file mode 100644 index 0000000..565d162 --- /dev/null +++ b/Infrastructure/Database/Demo/SalesReturnSeeder.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 SalesReturnSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.SalesReturn.AnyAsync()) return; + + var random = new Random(); + var entityName = nameof(SalesReturn); + var deliveryOrderEntityName = nameof(DeliveryOrder); + var ivtEntityName = nameof(InventoryTransaction); + + var deliveryOrders = await context.DeliveryOrder + .Where(x => x.Status >= DeliveryOrderStatus.Confirmed) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!warehouses.Any()) return; + + foreach (var deliveryOrder in deliveryOrders) + { + bool skip = random.Next(2) == 0; + + if (skip) continue; + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var salesReturn = new SalesReturn + { + AutoNumber = autoNo, + ReturnDate = deliveryOrder.DeliveryDate?.AddDays(random.Next(1, 5)), + Status = GetRandomStatus(random), + DeliveryOrderId = deliveryOrder.Id, + }; + await context.SalesReturn.AddAsync(salesReturn); + + var items = await context.InventoryTransaction + .Where(x => x.ModuleId == deliveryOrder.Id && x.ModuleName == deliveryOrderEntityName) + .ToListAsync(); + + foreach (var item in items) + { + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = salesReturn.Id, + ModuleName = entityName, + ModuleCode = "SRN", + ModuleNumber = salesReturn.AutoNumber, + MovementDate = salesReturn.ReturnDate!.Value, + Status = (InventoryTransactionStatus)salesReturn.Status, + AutoNumber = autoNoIVT, + WarehouseId = GetRandomValue(warehouses, random), + ProductId = item.ProductId, + Movement = item.Movement + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + await context.SaveChangesAsync(); + } + } + + private static SalesReturnStatus GetRandomStatus(Random random) + { + var statuses = new[] { SalesReturnStatus.Draft, SalesReturnStatus.Cancelled, SalesReturnStatus.Confirmed, SalesReturnStatus.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 SalesReturnStatus.Confirmed; + } + + 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/SalesTeamSeeder.cs b/Infrastructure/Database/Demo/SalesTeamSeeder.cs new file mode 100644 index 0000000..9d675b6 --- /dev/null +++ b/Infrastructure/Database/Demo/SalesTeamSeeder.cs @@ -0,0 +1,33 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class SalesTeamSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.SalesTeam.AnyAsync()) return; + + var salesTeams = new List + { + new SalesTeam { Name = "The Trailblazers" }, + new SalesTeam { Name = "Revenue Rockets" }, + new SalesTeam { Name = "Deal Makers" }, + new SalesTeam { Name = "Sales Ninjas" }, + new SalesTeam { Name = "Profit Pioneers" }, + new SalesTeam { Name = "Closing Crew" }, + new SalesTeam { Name = "Growth Gurus" }, + new SalesTeam { Name = "The Persuaders" }, + new SalesTeam { Name = "Market Mavens" }, + new SalesTeam { Name = "Sales Savants" } + }; + + foreach (var salesTeam in salesTeams) + { + await context.SalesTeam.AddAsync(salesTeam); + } + + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/ScrappingSeeder.cs b/Infrastructure/Database/Demo/ScrappingSeeder.cs new file mode 100644 index 0000000..6b028ca --- /dev/null +++ b/Infrastructure/Database/Demo/ScrappingSeeder.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 ScrappingSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.Scrapping.AnyAsync()) return; + + var random = new Random(); + var scrappingStatusValues = Enum.GetValues(typeof(ScrappingStatus)).Cast().ToList(); + var entityName = nameof(Scrapping); + var ivtEntityName = nameof(InventoryTransaction); + + var products = await context.Product + .Where(x => x.Physical == true) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!products.Any() || !warehouses.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 (var transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var scrapping = new Scrapping + { + AutoNumber = autoNo, + ScrappingDate = transDate, + Status = scrappingStatusValues[random.Next(scrappingStatusValues.Count)], + WarehouseId = GetRandomValue(warehouses, random), + }; + await context.Scrapping.AddAsync(scrapping); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = products[random.Next(products.Count)]; + + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = scrapping.Id, + ModuleName = entityName, + ModuleCode = "SCRP", + ModuleNumber = scrapping.AutoNumber, + MovementDate = scrapping.ScrappingDate!.Value, + Status = (InventoryTransactionStatus)scrapping.Status, + AutoNumber = autoNoIVT, + WarehouseId = scrapping.WarehouseId, + ProductId = product.Id, + Movement = (double)random.Next(1, 10) + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + var daysInMonth = DateTime.DaysInMonth(year, month); + return Enumerable.Range(1, count) + .Select(_ => new DateTime(year, month, random.Next(1, daysInMonth + 1))) + .ToArray(); + } + + private static T GetRandomValue(IList list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/StockCountSeeder.cs b/Infrastructure/Database/Demo/StockCountSeeder.cs new file mode 100644 index 0000000..c8ba8df --- /dev/null +++ b/Infrastructure/Database/Demo/StockCountSeeder.cs @@ -0,0 +1,114 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class StockCountSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.StockCount.AnyAsync()) return; + + var random = new Random(); + var stockCountStatusValues = Enum.GetValues(typeof(StockCountStatus)).Cast().ToList(); + var entityName = nameof(StockCount); + var ivtEntityName = nameof(InventoryTransaction); + + var products = await context.Product + .Where(x => x.Physical == true) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!products.Any() || !warehouses.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 (var transDate in transactionDates) + { + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var stockCount = new StockCount + { + AutoNumber = autoNo, + CountDate = transDate, + Status = stockCountStatusValues[random.Next(stockCountStatusValues.Count)], + WarehouseId = GetRandomValue(warehouses, random), + }; + await context.StockCount.AddAsync(stockCount); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = products[random.Next(products.Count)]; + + var stock = context.GetStock(stockCount.WarehouseId, product.Id); + var qtyCount = stock + (double)random.Next(-10, 10); + + if (qtyCount <= 0.0) + { + if (qtyCount < 0.0) + { + qtyCount = Math.Abs(qtyCount) * 2.0; + } + else if (qtyCount == 0.0) + { + qtyCount = qtyCount + (double)random.Next(40, 50); + } + } + + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = stockCount.Id, + ModuleName = entityName, + ModuleCode = "COUNT", + ModuleNumber = stockCount.AutoNumber, + MovementDate = stockCount.CountDate!.Value, + Status = (InventoryTransactionStatus)stockCount.Status, + AutoNumber = autoNoIVT, + WarehouseId = stockCount.WarehouseId, + ProductId = product.Id, + QtySCCount = qtyCount, + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + } + } + + await context.SaveChangesAsync(); + } + + private static DateTime[] GetRandomDays(int year, int month, int count) + { + var random = new Random(); + var daysInMonth = DateTime.DaysInMonth(year, month); + return Enumerable.Range(1, count) + .Select(_ => new DateTime(year, month, random.Next(1, daysInMonth + 1))) + .ToArray(); + } + + private static T GetRandomValue(IList list, Random random) + { + return list[random.Next(list.Count)]; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/TransferInSeeder.cs b/Infrastructure/Database/Demo/TransferInSeeder.cs new file mode 100644 index 0000000..9d9df61 --- /dev/null +++ b/Infrastructure/Database/Demo/TransferInSeeder.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 TransferInSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.TransferIn.AnyAsync()) return; + + var random = new Random(); + var transferStatusValues = Enum.GetValues(typeof(TransferStatus)).Cast().ToList(); + var entityName = nameof(TransferIn); + var transferOutEntityName = nameof(TransferOut); + var ivtEntityName = nameof(InventoryTransaction); + + var transferOuts = await context.TransferOut + .Where(x => x.Status >= TransferStatus.Confirmed) + .ToListAsync(); + + foreach (var transferOut in transferOuts) + { + bool skip = random.Next(2) == 0; + if (skip) continue; + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var transferIn = new TransferIn + { + AutoNumber = autoNo, + TransferReceiveDate = transferOut.TransferReleaseDate?.AddDays(random.Next(1, 5)), + Status = transferStatusValues[random.Next(transferStatusValues.Count)], + TransferOutId = transferOut.Id, + }; + await context.TransferIn.AddAsync(transferIn); + + var items = await context.InventoryTransaction + .Where(x => x.ModuleId == transferOut.Id && x.ModuleName == transferOutEntityName) + .ToListAsync(); + + foreach (var item in items) + { + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = transferIn.Id, + ModuleName = entityName, + ModuleCode = "TO-IN", + ModuleNumber = transferIn.AutoNumber, + MovementDate = transferIn.TransferReceiveDate!.Value, + Status = (InventoryTransactionStatus)transferIn.Status, + AutoNumber = autoNoIVT, + WarehouseId = transferOut.WarehouseToId, + ProductId = item.ProductId, + Movement = item.Movement + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + await context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Database/Demo/TransferOutSeeder.cs b/Infrastructure/Database/Demo/TransferOutSeeder.cs new file mode 100644 index 0000000..27b150d --- /dev/null +++ b/Infrastructure/Database/Demo/TransferOutSeeder.cs @@ -0,0 +1,111 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Data.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database.Demo; + +public static class TransferOutSeeder +{ + public static async Task GenerateDataAsync(AppDbContext context) + { + if (await context.TransferOut.AnyAsync()) return; + + var random = new Random(); + var transferStatusValues = Enum.GetValues(typeof(TransferStatus)).Cast().ToList(); + var entityName = nameof(TransferOut); + var ivtEntityName = nameof(InventoryTransaction); + + var products = await context.Product + .Where(x => x.Physical == true) + .ToListAsync(); + + var warehouses = await context.Warehouse + .Where(x => x.SystemWarehouse == false) + .Select(x => x.Id) + .ToListAsync(); + + if (!products.Any() || warehouses.Count < 2) 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)) + { + var transactionDates = GetRandomDays(date.Year, date.Month, 6); + + foreach (DateTime transDate in transactionDates) + { + var fromId = GetRandomValue(warehouses, random); + var toId = GetRandomValue(warehouses.Where(x => x != fromId).ToList(), random); + + var autoNo = await context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var transferOut = new TransferOut + { + AutoNumber = autoNo, + TransferReleaseDate = transDate, + Status = transferStatusValues[random.Next(transferStatusValues.Count)], + WarehouseFromId = fromId, + WarehouseToId = toId, + }; + await context.TransferOut.AddAsync(transferOut); + + int numberOfProducts = random.Next(3, 6); + for (int i = 0; i < numberOfProducts; i++) + { + var product = GetRandomValue(products, random); + + var autoNoIVT = await context.GenerateAutoNumberAsync( + entityName: ivtEntityName, + prefixTemplate: $"{ivtEntityName.ToShortNameConsonant(3)}/{{Year}}/" + ); + + var inventoryTransaction = new InventoryTransaction + { + ModuleId = transferOut.Id, + ModuleName = entityName, + ModuleCode = "TO-OUT", + ModuleNumber = transferOut.AutoNumber, + MovementDate = transferOut.TransferReleaseDate!.Value, + Status = (InventoryTransactionStatus)transferOut.Status, + AutoNumber = autoNoIVT, + WarehouseId = transferOut.WarehouseFromId, + ProductId = product.Id, + Movement = (double)random.Next(1, 10) + }; + + context.CalculateInvenTrans(inventoryTransaction); + await context.InventoryTransaction.AddAsync(inventoryTransaction); + } + + 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 index = random.Next(daysInMonth.Count); + selectedDays.Add(daysInMonth[index]); + daysInMonth.RemoveAt(index); + } + + 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/BudgetConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BudgetConfiguration.cs new file mode 100644 index 0000000..d8d152b --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BudgetConfiguration.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 BudgetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Title) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CampaignId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Campaign) + .WithMany() + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.BudgetDate); + builder.HasIndex(e => e.CampaignId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CampaignConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CampaignConfiguration.cs new file mode 100644 index 0000000..0ab3d5c --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CampaignConfiguration.cs @@ -0,0 +1,49 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CampaignConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Title) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SalesTeamId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.SalesTeam) + .WithMany() + .HasForeignKey(e => e.SalesTeamId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.BudgetList) + .WithOne(e => e.Campaign) + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(e => e.ExpenseList) + .WithOne(e => e.Campaign) + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(e => e.LeadList) + .WithOne(e => e.Campaign) + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.SalesTeamId); + builder.HasIndex(e => e.CampaignDateStart); + builder.HasIndex(e => e.CampaignDateFinish); + } +} \ 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/CreditNoteConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CreditNoteConfiguration.cs new file mode 100644 index 0000000..636d974 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CreditNoteConfiguration.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 CreditNoteConfiguration : 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.SalesReturnId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.SalesReturn) + .WithMany() + .HasForeignKey(e => e.SalesReturnId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.CreditNoteDate); + builder.HasIndex(e => e.SalesReturnId); + } +} \ 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/DebitNoteConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/DebitNoteConfiguration.cs new file mode 100644 index 0000000..5e1bbe7 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/DebitNoteConfiguration.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 DebitNoteConfiguration : 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.PurchaseReturnId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.PurchaseReturn) + .WithMany() + .HasForeignKey(e => e.PurchaseReturnId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.DebitNoteDate); + builder.HasIndex(e => e.PurchaseReturnId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/DeliveryOrderConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/DeliveryOrderConfiguration.cs new file mode 100644 index 0000000..295eb6d --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/DeliveryOrderConfiguration.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 DeliveryOrderConfiguration : 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.DeliveryDate); + builder.HasIndex(e => e.SalesOrderId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ExpenseConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ExpenseConfiguration.cs new file mode 100644 index 0000000..d3fd86a --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ExpenseConfiguration.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 ExpenseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Title) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CampaignId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Campaign) + .WithMany(e => e.ExpenseList) + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.ExpenseDate); + builder.HasIndex(e => e.CampaignId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/GoodsReceiveConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/GoodsReceiveConfiguration.cs new file mode 100644 index 0000000..e22c28e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/GoodsReceiveConfiguration.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 GoodsReceiveConfiguration : 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.ReceiveDate); + builder.HasIndex(e => e.PurchaseOrderId); + } +} \ 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/LeadActivityConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeadActivityConfiguration.cs new file mode 100644 index 0000000..0543ddf --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeadActivityConfiguration.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 LeadActivityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.LeadId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.AttachmentName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.Lead) + .WithMany(e => e.LeadActivities) + .HasForeignKey(e => e.LeadId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.LeadId); + builder.HasIndex(e => e.FromDate); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/LeadConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeadConfiguration.cs new file mode 100644 index 0000000..4e0e971 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeadConfiguration.cs @@ -0,0 +1,103 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LeadConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Title) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CompanyName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyDescription) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CompanyAddressStreet) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyAddressCity) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyAddressState) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyAddressZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyAddressCountry) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyPhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyFaxNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyEmail) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyWebsite) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyWhatsApp) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyFacebook) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyInstagram) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyTwitter) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ClosingNote) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CampaignId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.SalesTeamId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Campaign) + .WithMany(e => e.LeadList) + .HasForeignKey(e => e.CampaignId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.SalesTeam) + .WithMany() + .HasForeignKey(e => e.SalesTeamId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.LeadContacts) + .WithOne(e => e.Lead) + .HasForeignKey(e => e.LeadId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.LeadActivities) + .WithOne(e => e.Lead) + .HasForeignKey(e => e.LeadId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.CampaignId); + builder.HasIndex(e => e.SalesTeamId); + builder.HasIndex(e => e.DateProspecting); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/LeadContactConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeadContactConfiguration.cs new file mode 100644 index 0000000..65efe2e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeadContactConfiguration.cs @@ -0,0 +1,81 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LeadContactConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.LeadId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FullName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.AddressStreet) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AddressCity) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AddressState) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AddressZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AddressCountry) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FaxNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.MobileNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Email) + .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.Twitter) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Instagram) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AvatarName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.Lead) + .WithMany(e => e.LeadContacts) + .HasForeignKey(e => e.LeadId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.FullName); + builder.HasIndex(e => e.LeadId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/NegativeAdjustmentConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/NegativeAdjustmentConfiguration.cs new file mode 100644 index 0000000..595b9a2 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/NegativeAdjustmentConfiguration.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class NegativeAdjustmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.AdjustmentDate); + } +} \ 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/PositiveAdjustmentConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PositiveAdjustmentConfiguration.cs new file mode 100644 index 0000000..bf2017f --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PositiveAdjustmentConfiguration.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PositiveAdjustmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.AdjustmentDate); + } +} \ 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/PurchaseRequisitionConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PurchaseRequisitionConfiguration.cs new file mode 100644 index 0000000..0d9a271 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PurchaseRequisitionConfiguration.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 PurchaseRequisitionConfiguration : 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.PurchaseRequisitionItemList) + .WithOne(e => e.PurchaseRequisition) + .HasForeignKey(e => e.PurchaseRequisitionId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.RequisitionDate); + builder.HasIndex(e => e.VendorId); + builder.HasIndex(e => e.TaxId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PurchaseRequisitionItemConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PurchaseRequisitionItemConfiguration.cs new file mode 100644 index 0000000..90f6ff2 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PurchaseRequisitionItemConfiguration.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 PurchaseRequisitionItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.PurchaseRequisitionId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.PurchaseRequisition) + .WithMany(e => e.PurchaseRequisitionItemList) + .HasForeignKey(e => e.PurchaseRequisitionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Product) + .WithMany() + .HasForeignKey(e => e.ProductId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.PurchaseRequisitionId); + builder.HasIndex(e => e.ProductId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PurchaseReturnConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PurchaseReturnConfiguration.cs new file mode 100644 index 0000000..0c11173 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PurchaseReturnConfiguration.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 PurchaseReturnConfiguration : 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.GoodsReceiveId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.GoodsReceive) + .WithMany() + .HasForeignKey(e => e.GoodsReceiveId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.ReturnDate); + builder.HasIndex(e => e.GoodsReceiveId); + } +} \ 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/SalesQuotationConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesQuotationConfiguration.cs new file mode 100644 index 0000000..aba8b56 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesQuotationConfiguration.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 SalesQuotationConfiguration : 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.SalesQuotationItemList) + .WithOne(e => e.SalesQuotation) + .HasForeignKey(e => e.SalesQuotationId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.QuotationDate); + builder.HasIndex(e => e.CustomerId); + builder.HasIndex(e => e.TaxId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesQuotationItemConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesQuotationItemConfiguration.cs new file mode 100644 index 0000000..61c456a --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesQuotationItemConfiguration.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 SalesQuotationItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.SalesQuotationId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ProductId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Summary) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.SalesQuotation) + .WithMany(e => e.SalesQuotationItemList) + .HasForeignKey(e => e.SalesQuotationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.Product) + .WithMany() + .HasForeignKey(e => e.ProductId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.SalesQuotationId); + builder.HasIndex(e => e.ProductId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesRepresentativeConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesRepresentativeConfiguration.cs new file mode 100644 index 0000000..0d295e4 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesRepresentativeConfiguration.cs @@ -0,0 +1,45 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class SalesRepresentativeConfiguration : 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.EmployeeNumber) + .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.SalesTeamId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.SalesTeam) + .WithMany() + .HasForeignKey(e => e.SalesTeamId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.SalesTeamId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesReturnConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesReturnConfiguration.cs new file mode 100644 index 0000000..44ab622 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesReturnConfiguration.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 SalesReturnConfiguration : 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.DeliveryOrderId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.DeliveryOrder) + .WithMany() + .HasForeignKey(e => e.DeliveryOrderId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.ReturnDate); + builder.HasIndex(e => e.DeliveryOrderId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SalesTeamConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SalesTeamConfiguration.cs new file mode 100644 index 0000000..8b0ea4f --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SalesTeamConfiguration.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 SalesTeamConfiguration : 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.SalesRepresentativeList) + .WithOne(e => e.SalesTeam) + .HasForeignKey(e => e.SalesTeamId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/ScrappingConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ScrappingConfiguration.cs new file mode 100644 index 0000000..6d3532a --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ScrappingConfiguration.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 ScrappingConfiguration : 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.WarehouseId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Warehouse) + .WithMany() + .HasForeignKey(e => e.WarehouseId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.ScrappingDate); + builder.HasIndex(e => e.WarehouseId); + } +} \ 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/StockCountConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/StockCountConfiguration.cs new file mode 100644 index 0000000..e1c7f69 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/StockCountConfiguration.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 StockCountConfiguration : 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.WarehouseId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Warehouse) + .WithMany() + .HasForeignKey(e => e.WarehouseId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.CountDate); + builder.HasIndex(e => e.WarehouseId); + } +} \ 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/TransferInConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TransferInConfiguration.cs new file mode 100644 index 0000000..8d8591f --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TransferInConfiguration.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 TransferInConfiguration : 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.TransferOutId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.TransferOut) + .WithMany() + .HasForeignKey(e => e.TransferOutId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.AutoNumber).IsUnique(); + builder.HasIndex(e => e.TransferReceiveDate); + builder.HasIndex(e => e.TransferOutId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/TransferOutConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TransferOutConfiguration.cs new file mode 100644 index 0000000..a442387 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TransferOutConfiguration.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 TransferOutConfiguration : 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.WarehouseFromId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.WarehouseToId) + .HasMaxLength(GlobalConsts.StringLengthId); + + 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.TransferReleaseDate); + builder.HasIndex(e => e.WarehouseFromId); + builder.HasIndex(e => e.WarehouseToId); + } +} \ 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..d369f19 --- /dev/null +++ b/Shared/Consts/GlobalConsts.cs @@ -0,0 +1,40 @@ +namespace Indotalent.Shared.Consts; + +public static class GlobalConsts +{ + public const string AppInitial = "CRM"; + public const string AppName = "CRM: Customer Relationship Management"; + public const string AppTagline = "The Ultimate Bridge Between Your Business and Your Customers."; + + 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..67d0016 --- /dev/null +++ b/Shared/Utils/InventoryTransactionHelper.cs @@ -0,0 +1,126 @@ +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; + + case "TransferIn": + transaction.TransType = InventoryTransType.In; + transaction.WarehouseFromId = GetSystemWarehouseId(context, "TRANSIT"); + transaction.WarehouseToId = transaction.WarehouseId; + break; + + case "TransferOut": + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "TRANSIT"); + break; + + case "StockCount": + transaction.QtySCSys = GetStock(context, transaction.WarehouseId, transaction.ProductId, transaction.Id); + transaction.QtySCDelta = transaction.QtySCSys - transaction.QtySCCount; + transaction.Movement = Math.Abs(transaction.QtySCDelta ?? 0.0); + + if (transaction.QtySCDelta < 0.0) + { + transaction.TransType = InventoryTransType.In; + transaction.WarehouseFromId = GetSystemWarehouseId(context, "STOCK_COUNT"); + transaction.WarehouseToId = transaction.WarehouseId; + } + else + { + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "STOCK_COUNT"); + } + break; + + case "NegativeAdjustment": + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "ADJUSTMENT"); + break; + + case "PositiveAdjustment": + transaction.TransType = InventoryTransType.In; + transaction.WarehouseFromId = GetSystemWarehouseId(context, "ADJUSTMENT"); + transaction.WarehouseToId = transaction.WarehouseId; + break; + + case "Scrapping": + transaction.TransType = InventoryTransType.Out; + transaction.WarehouseFromId = transaction.WarehouseId; + transaction.WarehouseToId = GetSystemWarehouseId(context, "SCRAPPING"); + 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/PurchaseRequisitionHelper.cs b/Shared/Utils/PurchaseRequisitionHelper.cs new file mode 100644 index 0000000..c68d379 --- /dev/null +++ b/Shared/Utils/PurchaseRequisitionHelper.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Shared.Utils; + +public static class PurchaseRequisitionHelper +{ + public static void Recalculate(AppDbContext context, string requisitionId) + { + var requisition = context.PurchaseRequisition + .Include(x => x.Tax) + .SingleOrDefault(x => x.Id == requisitionId); + + if (requisition == null) return; + + var items = context.PurchaseRequisitionItem + .Where(x => x.PurchaseRequisitionId == requisitionId) + .ToList(); + + requisition.BeforeTaxAmount = items.Sum(x => (x.UnitPrice ?? 0m) * (decimal)(x.Quantity ?? 0.0)); + + var taxPercentage = requisition.Tax?.PercentageValue ?? 0; + requisition.TaxAmount = (requisition.BeforeTaxAmount ?? 0m) * (decimal)taxPercentage / 100m; + + requisition.AfterTaxAmount = (requisition.BeforeTaxAmount ?? 0m) + (requisition.TaxAmount ?? 0m); + + context.PurchaseRequisition.Update(requisition); + 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/SalesQuotationHelper.cs b/Shared/Utils/SalesQuotationHelper.cs new file mode 100644 index 0000000..20e016e --- /dev/null +++ b/Shared/Utils/SalesQuotationHelper.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Shared.Utils; + +public static class SalesQuotationHelper +{ + public static void Recalculate(AppDbContext context, string quotationId) + { + var quotation = context.SalesQuotation + .Include(x => x.Tax) + .SingleOrDefault(x => x.Id == quotationId); + + if (quotation == null) return; + + var items = context.SalesQuotationItem + .Where(x => x.SalesQuotationId == quotationId) + .ToList(); + + quotation.BeforeTaxAmount = items.Sum(x => (x.UnitPrice ?? 0m) * (decimal)(x.Quantity ?? 0.0)); + + var taxPercentage = quotation.Tax?.PercentageValue ?? 0; + quotation.TaxAmount = (quotation.BeforeTaxAmount ?? 0m) * (decimal)taxPercentage / 100m; + + quotation.AfterTaxAmount = (quotation.BeforeTaxAmount ?? 0m) + (quotation.TaxAmount ?? 0m); + + context.SalesQuotation.Update(quotation); + 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..ae7eb86 --- /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": "", + "ClientId": "", + "ClientSecret": "", + "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-CRM;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-CRM-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://crm.indotalent.com/avatars" }, + "Local": { "IsUsed": true, "StoragePath": "uploads", "BaseUrl": "https://crm.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..9caf1db --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# ===================================================================== +# Blazor CRM - Docker Compose untuk Portainer Stack +# ===================================================================== +# ASP.NET Core Blazor .NET 10 dengan database Blazor-CRM di MS SQL +# Routing via Nginx Proxy Manager (container name) +# ===================================================================== + +services: + blazor-crm: + build: + context: . + dockerfile: Dockerfile + container_name: blazor-crm + restart: unless-stopped + expose: + - 8080 + networks: + - indotalent-network + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + # MS SQL - database Blazor-CRM + - DatabaseSettings__MsSQL__ConnectionString=Server=mssql,1433;Database=Blazor-CRM;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + # Hangfire background job + - BackgroundJobSettings__Hangfire__ConnectionString=Server=mssql,1433;Database=Blazor-CRM-Hangfire;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + # Email SMTP + - 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 + mem_reservation: 256m + cpus: '0.5' + labels: + - "indotalent.service=blazor-crm" + - "indotalent.description=Blazor CRM - Customer Relationship Management" + +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