diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..1233895fadaa3832adf2de22f61b0a96e0e56aa1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.git/ +**/.github/ +**/.agents/ +**/.nuget-scratch/ +**/_codex_build/ +**/_codex_obj/ +.DS_Store +Thumbs.db diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..1ff0c423042b46cb1d617b81efb715defbe8054d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000000000000000000000000000000..e40201cf12a50546b63ac5cc44278d1c3a91bd61 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. +- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. +- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2078706916b798d6f825e045ad2e5701ba92504b --- /dev/null +++ b/.gitignore @@ -0,0 +1,368 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +_codex_build/ +_codex_obj/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Firebase service account keys +TaskTrackingSystem.WebApi/Firebase/*.json + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd diff --git a/.nuget-scratch/NuGet.Config b/.nuget-scratch/NuGet.Config new file mode 100644 index 0000000000000000000000000000000000000000..80f5bd7fccbfe34ae660ddf16fa18a8c01192256 --- /dev/null +++ b/.nuget-scratch/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/DEPLOY_HUGGINGFACE.md b/DEPLOY_HUGGINGFACE.md new file mode 100644 index 0000000000000000000000000000000000000000..fdc367d8c4fc6eca1402edc4aa2c1e321174a291 --- /dev/null +++ b/DEPLOY_HUGGINGFACE.md @@ -0,0 +1,49 @@ +# Hugging Face + Supabase Deployment Guide + +This repo is configured to run as a single Docker Space on Hugging Face. + +## What You Need + +- A Hugging Face account +- A Supabase project +- Your Supabase PostgreSQL connection string + +## Step By Step + +1. Create a Supabase project. +2. Open the database settings and copy the PostgreSQL connection string. +3. Push this repository to GitHub. +4. Create a new Hugging Face Space. +5. Choose `Docker` as the Space SDK. +6. Point the Space at this repository. +7. Make sure the Space uses the root [Dockerfile](./Dockerfile). +8. Add these Space secrets or variables: + - `ConnectionStrings__DefaultConnection` + - `JwtSettings__Key` + - `JwtSettings__Issuer` + - `JwtSettings__Audience` + - `PasswordReset__RecoveryCode` +9. Set `ConnectionStrings__DefaultConnection` to your Supabase PostgreSQL connection string. +10. Leave `WebApi__BaseUrl` unset unless you want to override the internal default. +11. Deploy the Space. + +## What the Container Does + +- Starts the Web API on `127.0.0.1:5001` +- Starts the Web App on `127.0.0.1:5000` +- Uses Nginx on port `7860` as the public entrypoint +- Proxies `/api/*` to the API +- Proxies everything else to the Web App + +## Notes + +- The API now creates the schema on startup if the database is empty. +- The model has been switched from SQL Server to PostgreSQL-friendly mappings. +- If deployment fails, the first thing to check is the Supabase connection string and whether the database is reachable. + +## First Login + +1. Open the Hugging Face Space URL. +2. Wait for the container to finish starting. +3. Try the default demo accounts from the repository README. +4. If login works, confirm that dashboard and task pages load normally. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2ba761d1da072fa96a5370d12ccc7dca930b5afc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY TaskTrackingSystem.sln ./ +COPY TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj TaskTrackingSystem.Database/ +COPY TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj TaskTrackingSystem.Shared/ +COPY TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj TaskTrackingSystem.WebApi/ +COPY TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj TaskTrackingSystem.WebApp/ + +RUN dotnet restore TaskTrackingSystem.sln + +COPY . . + +RUN dotnet publish TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj -c $BUILD_CONFIGURATION -o /app/publish/api /p:UseAppHost=false +RUN dotnet publish TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj -c $BUILD_CONFIGURATION -o /app/publish/webapp /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS final +WORKDIR /app + +ENV ASPNETCORE_ENVIRONMENT=Production +EXPOSE 7860 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish/api /app/api +COPY --from=build /app/publish/webapp /app/webapp +COPY nginx.conf /etc/nginx/nginx.conf +COPY start.sh /app/start.sh + +RUN chmod +x /app/start.sh + +CMD ["/app/start.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a2b845fb7afc87dfe106caf550166c1e0975baee --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +--- +title: TaskTrackingSystem +colorFrom: blue +colorTo: green +sdk: docker +app_port: 7860 +--- + +# TaskTrackingSystem + +This repo runs the Web API and Web App together inside one Docker container for Hugging Face Spaces. + +## Hosting Setup + +- Database: Supabase PostgreSQL +- Public app: Hugging Face Space +- API and Web App: same container, routed through Nginx + +## Local Notes + +- The app now expects PostgreSQL instead of SQL Server. +- The API creates the schema on first startup with `EnsureCreatedAsync()`. +- The Web App talks to the API at `http://127.0.0.1:5001/api/` by default inside the container. + +## Default Demo Accounts + +- `admin` / `P@ssw0rd123!` +- `mgr01` / `P@ssw0rd123!` +- `emp01` / `P@ssw0rd123!` diff --git a/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs b/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs new file mode 100644 index 0000000000000000000000000000000000000000..e4ef282d061286a6b146cbcf23785458b64222e5 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class AppDbContext : DbContext +{ + public AppDbContext() + { + } + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet AuditLogs { get; set; } + + public virtual DbSet Comments { get; set; } + + + public virtual DbSet Menus { get; set; } + + public virtual DbSet Notifications { get; set; } + + public virtual DbSet Permissions { get; set; } + + public virtual DbSet Issues { get; set; } + + public virtual DbSet Projects { get; set; } + + public virtual DbSet ProjectMembers { get; set; } + + public virtual DbSet Roles { get; set; } + + public virtual DbSet RoleMenus { get; set; } + + public virtual DbSet RolePermissions { get; set; } + + public virtual DbSet Tasks { get; set; } + + public virtual DbSet UserDevices { get; set; } + + public virtual DbSet Users { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=tts;Username=postgres;Password=postgres"); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.UserId, "IX_AuditLogs_UserId"); + + entity.Property(e => e.Action).HasMaxLength(100); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.IpAddress) + .HasMaxLength(45) + .IsUnicode(false); + entity.Property(e => e.Module).HasMaxLength(50); + + entity.HasOne(d => d.User).WithMany(p => p.AuditLogs) + .HasForeignKey(d => d.UserId) + .HasConstraintName("FK_AuditLogs_Users"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.TaskId, "IX_Comments_TaskId"); + + entity.HasIndex(e => e.UserId, "IX_Comments_UserId"); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.Task).WithMany(p => p.Comments) + .HasForeignKey(d => d.TaskId) + .HasConstraintName("FK_Comments_Tasks"); + + entity.HasOne(d => d.User).WithMany(p => p.Comments) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Comments_Users"); + }); + + + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.MenuCode, "UQ_Menus_MenuCode").IsUnique(); + + entity.Property(e => e.CreatedAt) + .HasPrecision(0) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.Property(e => e.Icon).HasMaxLength(50); + entity.Property(e => e.MenuCode).HasMaxLength(50); + entity.Property(e => e.MenuName).HasMaxLength(100); + entity.Property(e => e.MenuUrl).HasMaxLength(200); + entity.Property(e => e.UpdatedAt).HasPrecision(0); + entity.Property(e => e.Visible).HasDefaultValue(true); + + entity.HasOne(d => d.ParentMenu).WithMany(p => p.InverseParentMenu) + .HasForeignKey(d => d.ParentMenuId) + .HasConstraintName("FK_Menus_ParentMenu"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("notifications"); + + entity.HasIndex(e => e.CreatedAt, "IX_notifications_CreatedAt"); + entity.HasIndex(e => new { e.RecipientId, e.IsRead }, "IX_notifications_Recipient_IsRead"); + + entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("timestamp with time zone"); + entity.Property(e => e.IsRead).HasColumnName("is_read").HasDefaultValue(false); + entity.Property(e => e.NotificationType).HasColumnName("notification_type"); + entity.Property(e => e.Title).HasMaxLength(255); + entity.Property(e => e.ReadAt).HasColumnName("read_at").HasColumnType("timestamp with time zone"); + entity.Property(e => e.RecipientId).HasColumnName("recipient_id"); + entity.Property(e => e.SenderId).HasColumnName("sender_id"); + entity.Property(e => e.SourceId).HasColumnName("source_id"); + entity.Property(e => e.SourceType).HasColumnName("source_type").HasMaxLength(20); + + entity.HasOne(d => d.Recipient).WithMany() + .HasForeignKey(d => d.RecipientId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_notifications_Users_Recipient"); + + entity.HasOne(d => d.Sender).WithMany() + .HasForeignKey(d => d.SenderId) + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_notifications_Users_Sender"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.MenuId, "IX_Permissions_MenuId"); + + entity.HasIndex(e => e.PermissionCode, "UQ_Permissions_PermissionCode").IsUnique(); + + entity.Property(e => e.ActionName).HasMaxLength(100); + entity.Property(e => e.ApiName).HasMaxLength(100); + entity.Property(e => e.CreatedAt) + .HasPrecision(0) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.Property(e => e.HttpMethod).HasMaxLength(20); + entity.Property(e => e.PermissionCode).HasMaxLength(50); + entity.Property(e => e.UpdatedAt).HasPrecision(0); + entity.Property(e => e.Visible).HasDefaultValue(true); + + entity.HasOne(d => d.Menu).WithMany(p => p.Permissions) + .HasForeignKey(d => d.MenuId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Permissions_Menu"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.AssignedTo, "IX_Issues_AssignedTo"); + entity.HasIndex(e => e.StatusId, "IX_Issues_StatusId"); + entity.HasIndex(e => e.PriorityId, "IX_Issues_PriorityId"); + entity.HasIndex(e => e.TaskId, "IX_Issues_TaskId"); + + entity.Property(e => e.ActualHours).HasColumnType("decimal(5, 2)"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.Description).HasMaxLength(1000); + entity.Property(e => e.DelayReason).HasMaxLength(300); + entity.Property(e => e.BlockedBy).HasMaxLength(200); + entity.Property(e => e.DueDate).HasColumnType("timestamp with time zone"); + entity.Property(e => e.EscalationLevel).HasDefaultValue(0); + entity.Property(e => e.IsBlocked).HasDefaultValue(false); + entity.Property(e => e.IsDeleted).HasDefaultValue(false); + entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium); + entity.Property(e => e.StartDate).HasColumnType("timestamp with time zone"); + entity.Property(e => e.StatusId).HasDefaultValue(AppTaskStatus.Todo); + entity.Property(e => e.Title).HasMaxLength(200); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.AssignedToNavigation).WithMany() + .HasForeignKey(d => d.AssignedTo) + .HasConstraintName("FK_Issues_Users_AssignedTo"); + + entity.HasOne(d => d.Task).WithMany() + .HasForeignKey(d => d.TaskId) + .HasConstraintName("FK_Issues_Tasks"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.CreatedById, "IX_Projects_CreatedById"); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.CreatedDate) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.EndDate).HasColumnType("timestamp with time zone"); + entity.Property(e => e.Name).HasMaxLength(150); + entity.Property(e => e.StartDate).HasColumnType("timestamp with time zone"); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.CreatedByNavigation).WithMany(p => p.Projects) + .HasForeignKey(d => d.CreatedById) + .OnDelete(DeleteBehavior.ClientSetNull); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.ProjectId, e.UserId }); + + entity.HasIndex(e => e.UserId, "IX_ProjectMembers_UserId"); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.Project).WithMany(p => p.ProjectMembers) + .HasForeignKey(d => d.ProjectId) + .HasConstraintName("FK_ProjectMembers_Projects"); + + entity.HasOne(d => d.User).WithMany(p => p.ProjectMembers) + .HasForeignKey(d => d.UserId) + .HasConstraintName("FK_ProjectMembers_Users"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.Name, "UQ_Roles_Name").IsUnique(); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.Description).HasMaxLength(200); + entity.Property(e => e.Name).HasMaxLength(50); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.RoleMenuId).HasName("PK_RoleMenus_New"); + + entity.HasIndex(e => new { e.RoleId, e.MenuId }, "UQ_RoleMenus_Role_Menu").IsUnique(); + + entity.Property(e => e.CreatedAt) + .HasPrecision(0) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.Property(e => e.UpdatedAt).HasPrecision(0); + + entity.HasOne(d => d.Menu).WithMany(p => p.RoleMenus) + .HasForeignKey(d => d.MenuId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_RoleMenus_Menus"); + + entity.HasOne(d => d.Role).WithMany(p => p.RoleMenus) + .HasForeignKey(d => d.RoleId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_RoleMenus_Roles"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.PermissionId, "IX_RolePermissions_PermissionId"); + + entity.HasIndex(e => e.RoleId, "IX_RolePermissions_RoleId"); + + entity.HasIndex(e => new { e.RoleId, e.PermissionId }, "UQ_RolePermissions_Role_Permission").IsUnique(); + + entity.Property(e => e.CreatedAt) + .HasPrecision(0) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.Property(e => e.UpdatedAt).HasPrecision(0); + + entity.HasOne(d => d.Permission).WithMany(p => p.RolePermissions) + .HasForeignKey(d => d.PermissionId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_RolePermissions_Permissions"); + + entity.HasOne(d => d.Role).WithMany(p => p.RolePermissions) + .HasForeignKey(d => d.RoleId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_RolePermissions_Roles"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.AssignedBy, "IX_Tasks_AssignedBy"); + + entity.HasIndex(e => e.AssignedTo, "IX_Tasks_AssignedTo"); + + entity.HasIndex(e => e.ProjectId, "IX_Tasks_ProjectId"); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.DueDate).HasColumnType("timestamp with time zone"); + entity.Property(e => e.IsArchived).HasDefaultValue(false); + entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium); + entity.Property(e => e.StatusId).HasDefaultValue(AppTaskStatus.Todo); + entity.Property(e => e.Title).HasMaxLength(200); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.AssignedByNavigation).WithMany(p => p.TaskAssignedByNavigations).HasForeignKey(d => d.AssignedBy); + + entity.HasOne(d => d.AssignedToNavigation).WithMany(p => p.TaskAssignedToNavigations).HasForeignKey(d => d.AssignedTo); + + entity.HasOne(d => d.Project).WithMany(p => p.Tasks) + .HasForeignKey(d => d.ProjectId) + .HasConstraintName("FK_Tasks_Projects"); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.RoleId, "IX_Users_RoleId"); + + entity.HasIndex(e => e.Email, "UQ_Users_Email").IsUnique(); + + entity.HasIndex(e => e.Username, "UQ_Users_Username").IsUnique(); + + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnType("timestamp with time zone"); + entity.Property(e => e.Email).HasMaxLength(256); + entity.Property(e => e.FirstName).HasMaxLength(50); + entity.Property(e => e.IsActive).HasDefaultValue(true); + entity.Property(e => e.LastName).HasMaxLength(50); + entity.Property(e => e.Phone).HasMaxLength(20); + entity.Property(e => e.UpdatedAt).HasColumnType("timestamp with time zone"); + entity.Property(e => e.Username).HasMaxLength(50); + + entity.HasOne(d => d.Role).WithMany(p => p.Users) + .HasForeignKey(d => d.RoleId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Users_Roles"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("user_devices"); + + entity.HasIndex(e => e.UserId, "IX_user_devices_UserId"); + + entity.HasIndex(e => e.FcmToken, "UQ_user_devices_FcmToken").IsUnique(); + + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.FcmToken).HasColumnName("fcm_token").HasMaxLength(255); + entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("timestamp with time zone"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasColumnType("timestamp with time zone"); + + entity.HasOne(d => d.User).WithMany() + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_user_devices_Users"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/AuditLog.cs b/TaskTrackingSystem.Database/AppDbContextModels/AuditLog.cs new file mode 100644 index 0000000000000000000000000000000000000000..5a07bf96de177d4f4713fd0397495913ad80573f --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/AuditLog.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class AuditLog +{ + public long Id { get; set; } + + public long? UserId { get; set; } + + public string Action { get; set; } = null!; + + public string Module { get; set; } = null!; + + public string Description { get; set; } = null!; + + public string? IpAddress { get; set; } + + public DateTime? CreatedAt { get; set; } + + public virtual User? User { get; set; } +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Comment.cs b/TaskTrackingSystem.Database/AppDbContextModels/Comment.cs new file mode 100644 index 0000000000000000000000000000000000000000..f5491de5416b6758ee76271aa86c609edfd7a264 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Comment.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Comment +{ + public long Id { get; set; } + + public long TaskId { get; set; } + + public long UserId { get; set; } + + public string Message { get; set; } = null!; + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public virtual Task Task { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Issue.cs b/TaskTrackingSystem.Database/AppDbContextModels/Issue.cs new file mode 100644 index 0000000000000000000000000000000000000000..8206cf19584daeb496b42c991b76af77ba9d8121 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Issue.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Issue +{ + public long Id { get; set; } + + public long TaskId { get; set; } + + public string Title { get; set; } = null!; + + public string? Description { get; set; } + + public long? AssignedTo { get; set; } + + public decimal? EstimatedHours { get; set; } + + public decimal? ActualHours { get; set; } + + public string? DelayReason { get; set; } + + public bool IsBlocked { get; set; } + + public string? BlockedBy { get; set; } + + public int EscalationLevel { get; set; } + + public DateTime StartDate { get; set; } + + public DateTime DueDate { get; set; } + + public AppTaskStatus StatusId { get; set; } + + public TaskPriority PriorityId { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public virtual User? AssignedToNavigation { get; set; } + + public virtual Task Task { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Menu.cs b/TaskTrackingSystem.Database/AppDbContextModels/Menu.cs new file mode 100644 index 0000000000000000000000000000000000000000..9793ab32235ccf1a6ae178d8176256ed3c6fddb9 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Menu.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Menu +{ + public long MenuId { get; set; } + + public string MenuCode { get; set; } = null!; + + public long? ParentMenuId { get; set; } + + public string MenuName { get; set; } = null!; + + public string? MenuUrl { get; set; } + + public string? Icon { get; set; } + + public bool Visible { get; set; } + + public int OrderNo { get; set; } + + public bool IsDeleted { get; set; } + + public long? CreatedById { get; set; } + + public DateTime CreatedAt { get; set; } + + public long? UpdatedById { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual ICollection InverseParentMenu { get; set; } = new List(); + + public virtual Menu? ParentMenu { get; set; } + + public virtual ICollection Permissions { get; set; } = new List(); + + public virtual ICollection RoleMenus { get; set; } = new List(); +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs b/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs new file mode 100644 index 0000000000000000000000000000000000000000..301dd2a0c2855a8e7849e9c4e9623b6ca4800ca5 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs @@ -0,0 +1,32 @@ +using System; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Notification +{ + public long Id { get; set; } + + public long RecipientId { get; set; } + + public long? SenderId { get; set; } + + public byte NotificationType { get; set; } + + public string Title { get; set; } = null!; + + public string Body { get; set; } = null!; + + public string SourceType { get; set; } = null!; + + public long SourceId { get; set; } + + public bool IsRead { get; set; } + + public DateTime? ReadAt { get; set; } + + public DateTime? CreatedAt { get; set; } + + public virtual User Recipient { get; set; } = null!; + + public virtual User? Sender { get; set; } +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Permission.cs b/TaskTrackingSystem.Database/AppDbContextModels/Permission.cs new file mode 100644 index 0000000000000000000000000000000000000000..f8bf3d5d72503a1fcb316c09e09d1c6f8089e782 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Permission.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Permission +{ + public long PermissionId { get; set; } + + public string PermissionCode { get; set; } = null!; + + public long MenuId { get; set; } + + public string ActionName { get; set; } = null!; + + public string ApiName { get; set; } = null!; + + public string? HttpMethod { get; set; } + + public bool Visible { get; set; } + + public int OrderNo { get; set; } + + public bool IsDeleted { get; set; } + + public long? CreatedById { get; set; } + + public DateTime CreatedAt { get; set; } + + public long? UpdatedById { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Menu Menu { get; set; } = null!; + + public virtual ICollection RolePermissions { get; set; } = new List(); +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Project.cs b/TaskTrackingSystem.Database/AppDbContextModels/Project.cs new file mode 100644 index 0000000000000000000000000000000000000000..20b9ac894e119908cb117b967faab286a49c5934 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Project.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Project +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public string? Description { get; set; } + + public DateTime StartDate { get; set; } + + public DateTime EndDate { get; set; } + + public long CreatedById { get; set; } + + public DateTime? CreatedDate { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public int? BudgetedHours { get; set; } + + public virtual User CreatedByNavigation { get; set; } = null!; + + public virtual ICollection ProjectMembers { get; set; } = new List(); + + public virtual ICollection Tasks { get; set; } = new List(); +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/ProjectMember.cs b/TaskTrackingSystem.Database/AppDbContextModels/ProjectMember.cs new file mode 100644 index 0000000000000000000000000000000000000000..29681d9846f460787b7975890ad4bd7713bfb2be --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/ProjectMember.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class ProjectMember +{ + public long ProjectId { get; set; } + + public long UserId { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public virtual Project Project { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Role.cs b/TaskTrackingSystem.Database/AppDbContextModels/Role.cs new file mode 100644 index 0000000000000000000000000000000000000000..a39f94de6f61497c9d771228e32113dbf353c284 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Role.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Role +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public string? Description { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public virtual ICollection RoleMenus { get; set; } = new List(); + + public virtual ICollection RolePermissions { get; set; } = new List(); + + public virtual ICollection Users { get; set; } = new List(); +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/RoleMenu.cs b/TaskTrackingSystem.Database/AppDbContextModels/RoleMenu.cs new file mode 100644 index 0000000000000000000000000000000000000000..4abe87f445b118679bbbf567bbc6605a49bf309a --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/RoleMenu.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class RoleMenu +{ + public long RoleMenuId { get; set; } + + public long RoleId { get; set; } + + public long MenuId { get; set; } + + public bool IsDeleted { get; set; } + + public long? CreatedById { get; set; } + + public DateTime CreatedAt { get; set; } + + public long? UpdatedById { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Menu Menu { get; set; } = null!; + + public virtual Role Role { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/RolePermission.cs b/TaskTrackingSystem.Database/AppDbContextModels/RolePermission.cs new file mode 100644 index 0000000000000000000000000000000000000000..7257f2032c2c8c8ae9cbd15c53852b5de7ed972a --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/RolePermission.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class RolePermission +{ + public long RolePermissionId { get; set; } + + public long RoleId { get; set; } + + public long PermissionId { get; set; } + + public bool IsDeleted { get; set; } + + public long? CreatedById { get; set; } + + public DateTime CreatedAt { get; set; } + + public long? UpdatedById { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Permission Permission { get; set; } = null!; + + public virtual Role Role { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Task.cs b/TaskTrackingSystem.Database/AppDbContextModels/Task.cs new file mode 100644 index 0000000000000000000000000000000000000000..1f7e4e4f7a3c40593e60e28b78635421668b6571 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Task.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Task +{ + public long Id { get; set; } + + public string Title { get; set; } = null!; + + public string? Description { get; set; } + + public long ProjectId { get; set; } + + public AppTaskStatus StatusId { get; set; } + + public TaskPriority PriorityId { get; set; } + + public long? AssignedTo { get; set; } + + public long? AssignedBy { get; set; } + + public DateTime DueDate { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public bool IsArchived { get; set; } + + public virtual User? AssignedByNavigation { get; set; } + + public virtual User? AssignedToNavigation { get; set; } + + public virtual ICollection Comments { get; set; } = new List(); + + public virtual Project Project { get; set; } = null!; + +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/User.cs b/TaskTrackingSystem.Database/AppDbContextModels/User.cs new file mode 100644 index 0000000000000000000000000000000000000000..a1b2e23af91249a33595f0dac18131898817f1ba --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/User.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class User +{ + public long Id { get; set; } + + public string Username { get; set; } = null!; + + public string FirstName { get; set; } = null!; + + public string LastName { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string PasswordHash { get; set; } = null!; + + public string? Phone { get; set; } + + public long RoleId { get; set; } + + public bool IsActive { get; set; } + + public DateTime? CreatedAt { get; set; } + + public long? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long? UpdatedBy { get; set; } + + public bool IsDeleted { get; set; } + + public virtual ICollection AuditLogs { get; set; } = new List(); + + public virtual ICollection Comments { get; set; } = new List(); + + public virtual ICollection ProjectMembers { get; set; } = new List(); + + public virtual ICollection Projects { get; set; } = new List(); + + public virtual Role Role { get; set; } = null!; + + public virtual ICollection TaskAssignedByNavigations { get; set; } = new List(); + + public virtual ICollection TaskAssignedToNavigations { get; set; } = new List(); + +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs b/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs new file mode 100644 index 0000000000000000000000000000000000000000..813d2853c6af39e9716567655ae33ad06b8b70a0 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs @@ -0,0 +1,18 @@ +using System; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class UserDevice +{ + public long Id { get; set; } + + public long UserId { get; set; } + + public string FcmToken { get; set; } = null!; + + public DateTime? CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual User User { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/Scripts/seed_supabase.sql b/TaskTrackingSystem.Database/Scripts/seed_supabase.sql new file mode 100644 index 0000000000000000000000000000000000000000..e83e223554496123e684d67e3cfc69efe98b8085 --- /dev/null +++ b/TaskTrackingSystem.Database/Scripts/seed_supabase.sql @@ -0,0 +1,687 @@ +-- Automatically generated PostgreSQL seed script converted from SQL Server dump +BEGIN; +SET session_replication_role = 'replica'; + +-- Clean up existing data to ensure idempotent/re-runnable execution +DELETE FROM "AuditLogs"; +DELETE FROM "Comments"; +DELETE FROM "Issues"; +DELETE FROM "notifications"; +DELETE FROM "user_devices"; +DELETE FROM "Tasks"; +DELETE FROM "ProjectMembers"; +DELETE FROM "Projects"; +DELETE FROM "RolePermissions"; +DELETE FROM "RoleMenus"; +DELETE FROM "Permissions"; +DELETE FROM "Menus"; +DELETE FROM "Users"; +DELETE FROM "Roles"; + +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (1, 1, 'AssignAccess', 'Role', 'Assigned access permissions to role ''Manager'' (ID: 2)', '127.0.0.1', '2026-06-17T06:57:54.897'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (2, 1, 'AssignAccess', 'Role', 'Assigned access permissions to role ''Manager'' (ID: 2)', '127.0.0.1', '2026-06-17T06:59:13.933'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (3, 2, 'AssignAccess', 'Role', 'Assigned access permissions to role ''Employee'' (ID: 3)', '127.0.0.1', '2026-06-17T06:59:40.470'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (4, 1, 'AssignAccess', 'Role', 'Assigned access permissions to role ''Admin'' (ID: 1)', '127.0.0.1', '2026-06-17T07:06:35.453'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (5, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Revoked: Roles, Roles — Create, Roles — Delete, Roles — List, Roles — Update.', '127.0.0.1', '2026-06-17T07:39:06.907'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (6, 1, 'Create', 'User', 'Created user ''mgr11'' (Htoo Aung Lwin, mgr01@gmail.com) with role ''Manager''', '127.0.0.1', '2026-06-17T07:48:28.227'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (7, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. No changes detected.', '127.0.0.1', '2026-06-17T07:50:23.830'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (8, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. No changes detected.', '127.0.0.1', '2026-06-17T08:43:22.657'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (9, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Task Backlog.', '127.0.0.1', '2026-06-17T09:56:55.283'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (10, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Revoked: Task Backlog.', '127.0.0.1', '2026-06-17T09:58:15.990'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (11, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Overdue Tasks, Project Progress, Reports, Task Backlog, Task Report. Revoked: Tasks — Update.', '127.0.0.1', '2026-06-17T10:20:39.323'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (12, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. Granted: Task Backlog.', '127.0.0.1', '2026-06-17T14:55:11.923'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (13, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Granted: Users, Users — List.', '127.0.0.1', '2026-06-18T04:05:09.983'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (14, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Revoked: Users, Users — List.', '127.0.0.1', '2026-06-18T04:05:50.503'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (15, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Granted: Users, Users — List.', '127.0.0.1', '2026-06-18T04:24:06.327'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (16, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Granted: Roles, Roles — List. Revoked: Users, Users — List.', '127.0.0.1', '2026-06-18T04:24:39.533'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (17, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Granted: Roles — Create, Roles — Delete, Roles — Update, Task Backlog, Users, Users — Create, Users — Delete, Users — List, Users — Update.', '127.0.0.1', '2026-06-18T04:32:47.420'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (18, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Revoked: Roles, Roles — Create, Roles — Delete, Roles — List, Roles — Update, Task Backlog, Users, Users — Create, Users — Delete, Users — List, Users — Update.', '127.0.0.1', '2026-06-18T04:33:21.063'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (19, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Employee Report, Kanban Board, Project Assign, Projects — Create, Projects — Delete, Projects — Update, Roles, Roles — Create, Roles — Delete, Roles — List, Roles — Update, Task Assign, Tasks — Create, Tasks — Delete, Tasks — Update, Time Tracking, Users, Users — Create, Users — Delete, Users — List, Users — Update.', '127.0.0.1', '2026-06-18T04:33:33.847'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (20, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Revoked: Project Assign, Project List, Projects, Projects — Create, Projects — Delete, Projects — List, Projects — Update, Roles, Roles — Create, Roles — Delete, Roles — List, Roles — Update, Task Assign, Users, Users — Create, Users — Delete, Users — List, Users — Update.', '127.0.0.1', '2026-06-18T04:33:59.793'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (21, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Project List, Projects, Projects — List.', '127.0.0.1', '2026-06-18T04:35:14.170'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (22, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Revoked: Projects — Create, Projects — Delete, Projects — Update.', '127.0.0.1', '2026-06-18T04:37:39.747'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (23, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Granted: Projects — Create, Projects — Delete, Projects — Update.', '127.0.0.1', '2026-06-18T07:57:56.150'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (24, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Manager''. Revoked: Project Progress.', '127.0.0.1', '2026-06-18T09:04:05.613'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (25, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. No changes detected.', '127.0.0.1', '2026-06-18T12:17:20.890'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (26, 1, 'Create', 'Role', 'Created new role ''test''', '127.0.0.1', '2026-06-19T13:02:06.127'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (27, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''test''. No changes detected.', '127.0.0.1', '2026-06-19T13:02:06.907'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (28, 1, 'Create', 'User', 'Created user ''testing'' (test ing, testing@gmail.com) with role ''test''', '127.0.0.1', '2026-06-19T13:02:46.937'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (29, 1, 'Delete', 'User', 'Deleted user account ''testing'' (test ing)', '127.0.0.1', '2026-06-19T13:06:44.480'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (30, 1, 'Delete', 'Role', 'Deleted role ''test''', '127.0.0.1', '2026-06-19T13:06:51.520'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (31, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Revoked: Tasks — Create, Tasks — Delete, Tasks — Update.', '127.0.0.1', '2026-06-19T14:22:20.200'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (32, 1, 'Create', 'User', 'Created user ''maungaye'' (Maung Aye, maungaye@gmail.com) with role ''Employee''', '127.0.0.1', '2026-06-22T04:13:19.547'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (33, 1, 'Create', 'Role', 'Created new role ''Supervisor''', '127.0.0.1', '2026-06-22T04:15:08.430'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (34, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Supervisor''. Granted: Dashboard, Project List, Projects, Projects — Create, Projects — List.', '127.0.0.1', '2026-06-22T04:15:08.980'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (35, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. Granted: Add Issue, Add Issue — Create, Add Issue — Delete, Add Issue — List, Add Issue — Update. Revoked: 1.', '127.0.0.1', '2026-07-01T06:53:19.993'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (36, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Add Issue, Add Issue — Create, Add Issue — Delete, Add Issue — List, Add Issue — Update. Revoked: 1.', '127.0.0.1', '2026-07-01T07:11:49.900'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (37, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. No changes detected.', '127.0.0.1', '2026-07-01T07:50:14.773'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (38, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. Granted: Issue List. Revoked: 17.', '127.0.0.1', '2026-07-01T08:19:03.890'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (39, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Employee''. Granted: Issue List. Revoked: 17.', '127.0.0.1', '2026-07-02T03:39:57.093'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (40, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. Revoked: Audit Logs — List.', '127.0.0.1', '2026-07-03T07:15:03.567'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (41, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. Granted: Audit Logs — List.', '127.0.0.1', '2026-07-03T07:15:14.353'); +INSERT INTO "AuditLogs" ("Id", "UserId", "Action", "Module", "Description", "IpAddress", "CreatedAt") VALUES (42, 1, 'AssignAccess', 'Role', 'Updated access permissions for role ''Admin''. No changes detected.', '127.0.0.1', '2026-07-03T07:23:59.247'); +INSERT INTO "Comments" ("Id", "TaskId", "UserId", "Message", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (1, 7, 12, 'pee p', '2026-06-17T08:30:02.243', 12, NULL, NULL, FALSE); +INSERT INTO "Comments" ("Id", "TaskId", "UserId", "Message", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (2, 41, 12, 'hi', '2026-06-17T08:44:20.520', 12, '2026-06-17T08:44:25.987', 12, TRUE); +INSERT INTO "Comments" ("Id", "TaskId", "UserId", "Message", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (3, 41, 12, 'hihi', '2026-06-17T08:44:32.903', 12, NULL, NULL, FALSE); +INSERT INTO "Comments" ("Id", "TaskId", "UserId", "Message", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (4, 1, 1, 'hihi', '2026-06-18T12:19:43.717', 1, NULL, NULL, FALSE); +INSERT INTO "Comments" ("Id", "TaskId", "UserId", "Message", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (5, 117, 1, 'Hello', '2026-06-22T04:15:52.130', 1, NULL, NULL, FALSE); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (1, 117, 'Api test', 'Api Test', 1, 2, 12, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2026-07-01T07:14:40.177', 12, '2026-07-02T11:43:57.570', 1, FALSE, '2026-07-01', '2026-07-01', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (2, 117, 'Check Firewalls', NULL, 1, 2, 12, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2026-07-02T03:41:37.057', 12, '2026-07-02T11:43:57.570', 1, FALSE, '2026-07-02', '2026-07-02', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (3, 129, 'Refine user stories for Yangon Retail Upgrade retail checkout flow', 'Follow-up work for requirements workshop', 1, 2, 12, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-01-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-01-03', '2027-01-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (4, 130, 'Implement core point-of-sale (POS) integration for Yangon Retail', 'Follow-up work for implementation', 2, 3, 13, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-01-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-01-06', '2027-01-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (5, 131, 'QA validation on multi-currency checkout for Yangon Retail', 'Follow-up work for validation', 3, 1, 12, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-01-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-01-09', '2027-01-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (6, 132, 'Draft architecture design for Mandalay Service Portal customer dashboard', 'Follow-up work for requirements workshop', 1, 2, 14, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-02-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-02-03', '2027-02-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (7, 133, 'Develop service request submission form and state management', 'Follow-up work for implementation', 2, 3, 15, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-02-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-02-06', '2027-02-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (8, 134, 'UAT testing on customer service portal ticket routing', 'Follow-up work for validation', 3, 1, 14, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-02-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-02-09', '2027-02-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (9, 135, 'Review statutory tax calculation requirements for Payroll Hub', 'Follow-up work for requirements workshop', 1, 2, 16, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-03-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-03-03', '2027-03-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (10, 136, 'Build automated monthly payroll processing engine', 'Follow-up work for implementation', 2, 3, 17, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-03-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-03-06', '2027-03-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (11, 137, 'Verify payslip PDF generation accuracy against legacy data', 'Follow-up work for validation', 3, 1, 16, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-03-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-03-09', '2027-03-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (12, 138, 'Define vendor onboarding API schemas and authentication flows', 'Follow-up work for requirements workshop', 1, 2, 18, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-04-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-04-03', '2027-04-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (13, 139, 'Create vendor registration wizard and document upload UI', 'Follow-up work for implementation', 2, 3, 19, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-04-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-04-06', '2027-04-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (14, 140, 'Security audit and validation on vendor document upload storage bucket', 'Follow-up work for validation', 3, 1, 18, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-04-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-04-09', '2027-04-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (15, 141, 'Analyze real-time GPS tracking requirements and background worker sync interval', 'Follow-up work for requirements workshop', 1, 2, 20, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-05-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-05-03', '2027-05-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (16, 142, 'Integrate Google Maps API and implement driver location polling backend', 'Follow-up work for implementation', 2, 3, 21, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-05-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-05-06', '2027-05-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (17, 143, 'End-to-end testing on driver app background location services', 'Follow-up work for validation', 3, 1, 20, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-05-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-05-09', '2027-05-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (18, 144, 'Document offline data sync constraints for Mawlamyine Field App', 'Follow-up work for requirements workshop', 1, 2, 22, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-06-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-06-03', '2027-06-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (19, 145, 'Implement SQLite local database and sync queue for offline mode', 'Follow-up work for implementation', 2, 3, 23, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-06-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-06-06', '2027-06-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (20, 146, 'Perform offline-to-online network reconnection and conflict resolution testing', 'Follow-up work for validation', 3, 1, 22, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-06-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-06-09', '2027-06-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (21, 147, 'Finalize KPI metrics and chart visualization specs for Pathein Sales Dashboard', 'Follow-up work for requirements workshop', 1, 2, 24, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-07-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-07-03', '2027-07-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (22, 148, 'Build aggregate sales reporting APIs with Redis caching layer', 'Follow-up work for implementation', 2, 3, 25, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-07-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-07-06', '2027-07-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (23, 149, 'Cross-browser UI/UX testing on dashboard charts and dark mode toggle', 'Follow-up work for validation', 3, 1, 24, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-07-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-07-09', '2027-07-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (24, 150, 'Map out insurance claim workflow rules and approval hierarchy', 'Follow-up work for requirements workshop', 1, 2, 26, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-08-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-08-03', '2027-08-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (25, 151, 'Develop claims auto-adjudication rules engine', 'Follow-up work for implementation', 2, 3, 27, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-08-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-08-06', '2027-08-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (26, 152, 'Sanity testing on claims approval routing and email notifications', 'Follow-up work for validation', 3, 1, 26, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-08-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-08-09', '2027-08-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (27, 153, 'Gather warehouse inventory management and bin allocation requirements', 'Follow-up work for requirements workshop', 1, 2, 28, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-09-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-09-03', '2027-09-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (28, 154, 'Implement barcode scanning API integration for inventory tracking', 'Follow-up work for implementation', 2, 3, 29, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-09-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-09-06', '2027-09-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (29, 155, 'Stress test inventory database under simulated high-volume peak hours', 'Follow-up work for validation', 3, 1, 28, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-09-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-09-09', '2027-09-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (30, 156, 'Review subscription billing cycle edge cases and prorated refund rules', 'Follow-up work for requirements workshop', 1, 2, 30, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-10-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-10-03', '2027-10-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (31, 157, 'Integrate Stripe billing API for automated monthly recurring invoices', 'Follow-up work for implementation', 2, 3, 31, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-10-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-10-06', '2027-10-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (32, 158, 'Validate automated dunning emails and failed payment retry mechanisms', 'Follow-up work for validation', 3, 1, 30, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-10-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-10-09', '2027-10-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (33, 159, 'Scope chat widget integration and customer care SLA triggers', 'Follow-up work for requirements workshop', 1, 2, 32, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-11-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-11-03', '2027-11-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (34, 160, 'Embed live chat widget into customer portal with routing rules', 'Follow-up work for implementation', 2, 3, 33, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-11-06T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-11-06', '2027-11-11', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (35, 161, 'QA check on chat history persistence and agent assignment logic', 'Follow-up work for validation', 3, 1, 32, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-11-09T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-11-09', '2027-11-14', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (36, 162, 'Assess resource allocation tracking and scheduling UI requirements', 'Follow-up work for requirements workshop', 1, 2, 34, CAST(7.00 AS Decimal(5, 2)), CAST(7.00 AS Decimal(5, 2)), '2027-12-03T11:00:00.000', 1, '2026-07-02T11:43:57.570', 1, FALSE, '2027-12-03', '2027-12-08', NULL, FALSE, NULL, 0); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (37, 163, 'Develop interactive Gantt chart view for project resource scheduling', 'Follow-up work for implementation', 2, 3, 35, NULL, CAST(7.00 AS Decimal(5, 2)), '2027-12-06T11:00:00.000', 1, '2026-07-03T09:09:11.417', 1, FALSE, '2027-12-06', '2027-12-11', NULL, FALSE, NULL, 3); +INSERT INTO "Issues" ("Id", "TaskId", "Title", "Description", "StatusId", "PriorityId", "AssignedTo", "EstimatedHours", "ActualHours", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "StartDate", "DueDate", "DelayReason", "IsBlocked", "BlockedBy", "EscalationLevel") VALUES (38, 164, 'Validate drag-and-drop event triggers on the operations timeline view', 'Follow-up work for validation', 3, 1, 34, NULL, CAST(7.00 AS Decimal(5, 2)), '2027-12-09T11:00:00.000', 1, '2026-07-03T09:09:00.740', 1, FALSE, '2027-12-09', '2027-12-14', NULL, FALSE, NULL, 3); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (1, 'DASHBOARD', NULL, 'Dashboard', NULL, 'layout-grid', FALSE, 0, TRUE, 1, '2026-06-01T08:00:00.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (2, 'PROJECTS', NULL, 'Projects', NULL, 'folder-kanban', TRUE, 2, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (3, 'PROJECTS_LIST', 2, 'Project List', '/projects/all', 'list', TRUE, 3, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (4, 'PROJECTS_ASSIGN', 2, 'Project Assign', '/projects/assign', 'users-round', TRUE, 4, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (5, 'TASKS', NULL, 'Tasks', NULL, 'check-square', TRUE, 5, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (6, 'TASKS_LIST', 5, 'Task List', '/tasks/all', 'list-todo', TRUE, 6, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (7, 'TASKS_BOARD', 5, 'Kanban Board', '/board', 'columns-3', TRUE, 7, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (8, 'TASKS_ASSIGN', 5, 'Task Assign', '/tasks/assign', 'user-check', TRUE, 8, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (9, 'REPORTS', NULL, 'Reports', NULL, 'chart-column', TRUE, 9, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (10, 'REPORTS_TASKS', 9, 'Task Report', '/reports/tasks', 'file-text', TRUE, 10, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (11, 'REPORTS_TIMESHEET', 9, 'Time Tracking', '/reports/timesheet', 'clock-3', TRUE, 11, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (12, 'REPORTS_OVERDUE', 9, 'Overdue Tasks', '/reports/overdue', 'alarm-clock', TRUE, 12, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (13, 'REPORTS_EMPLOYEES', 9, 'Employee Report', '/reports/employees', 'users', TRUE, 13, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (14, 'REPORTS_PROJECTS', 9, 'Project Progress', '/reports/projects', 'subtitles', TRUE, 14, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (15, 'USERS', NULL, 'Users', '/users', 'users-round', TRUE, 15, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (16, 'ROLES', NULL, 'Roles', '/roles', 'shield-user', TRUE, 16, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (17, 'TASKS_BACKLOG', NULL, 'Task Backlog', '/tasks/backlog', 'layers', TRUE, 25, TRUE, NULL, '2026-06-17T09:32:51.0000000', 1, '2026-07-01T14:40:14.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (18, 'DASHBOARD_WIDGETS', NULL, 'Role Layouts', '/widget-setup', 'layout-grid', TRUE, 26, FALSE, NULL, '2026-06-19T04:44:59.0000000', NULL, '2026-06-19T06:48:19.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (19, 'DASHBOARD_ADMIN', NULL, 'Dashboard', '/dashboard', 'layout-dashboard', TRUE, 0, FALSE, NULL, '2026-06-19T07:44:16.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (20, 'DASHBOARD_MANAGER', NULL, 'Dashboard', '/dashboard/manager', 'layout-dashboard', TRUE, 0, FALSE, NULL, '2026-06-19T08:05:25.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (21, 'DASHBOARD_EMPLOYEE', NULL, 'Dashboard', '/dashboard/employee', 'layout-dashboard', TRUE, 0, FALSE, NULL, '2026-06-19T08:05:25.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (22, 'ISSUES_ADD', 5, 'Add Issue', '/issues/add', 'circle-plus', TRUE, 26, FALSE, 1, '2026-07-01T11:00:47.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (23, 'ISSUES_LIST', 5, 'Issue List', '/issues/all', 'list-todo', TRUE, 27, FALSE, 1, '2026-07-01T14:38:08.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (24, 'AUDIT_LOGS', NULL, 'Audit Logs', '/audit-logs', 'file-text', TRUE, 28, FALSE, 1, '2026-07-03T07:14:40.0000000', NULL, NULL); +INSERT INTO "Menus" ("MenuId", "MenuCode", "ParentMenuId", "MenuName", "MenuUrl", "Icon", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (25, 'REPORTS_ISSUES', 9, 'Issue Report', '/reports/issues', 'file-pen-line', TRUE, 15, FALSE, NULL, '2026-07-03T08:42:56.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (1, 14, 1, 2, 'task', 1, TRUE, '2026-06-18T10:23:03.7644258', '2026-06-18T10:16:06.9643960', 'Task status changed', 'Task ''Portal landing page'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (2, 2, 1, 2, 'task', 1, FALSE, NULL, '2026-06-18T10:16:06.9645153', 'Task status changed', 'Task ''Portal landing page'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (3, 14, 1, 2, 'task', 1, TRUE, '2026-06-18T12:46:13.7896047', '2026-06-18T12:19:30.7116861', 'Task status changed', 'Task ''Portal landing page'' changed from In Progress to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (4, 2, 1, 2, 'task', 1, FALSE, NULL, '2026-06-18T12:19:30.7117582', 'Task status changed', 'Task ''Portal landing page'' changed from In Progress to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (5, 14, 1, 2, 'task', 1, TRUE, '2026-06-18T12:46:12.7694234', '2026-06-18T12:19:33.3325815', 'Task status changed', 'Task ''Portal landing page'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (6, 2, 1, 2, 'task', 1, FALSE, NULL, '2026-06-18T12:19:33.3325820', 'Task status changed', 'Task ''Portal landing page'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (7, 14, 1, 5, 'task', 1, TRUE, '2026-06-18T12:46:11.7093777', '2026-06-18T12:19:43.8271666', 'New comment', 'System Admin commented on task ''Portal landing page'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (8, 2, 1, 5, 'task', 1, FALSE, NULL, '2026-06-18T12:19:43.8271680', 'New comment', 'System Admin commented on task ''Portal landing page'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (9, 14, 1, 2, 'task', 1, TRUE, '2026-06-18T12:46:10.8180933', '2026-06-18T12:33:55.7001972', 'Task status changed', 'Task ''Portal landing page'' changed from In Progress to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (10, 2, 1, 2, 'task', 1, FALSE, NULL, '2026-06-18T12:33:55.7001983', 'Task status changed', 'Task ''Portal landing page'' changed from In Progress to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (11, 2, 1, 8, 'project', 8, FALSE, NULL, '2026-06-19T06:20:08.5442869', 'Project assigned', 'You have been assigned to project ''Testing'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (14, 7, 12, 2, 'task', 41, FALSE, NULL, '2026-06-19T09:30:33.6906496', 'Task status changed', 'Task ''Customer Report '' changed from In Progress to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (15, 3, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2048589', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (16, 15, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2050369', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (17, 16, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2050376', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (18, 17, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2050379', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (19, 18, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2050382', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (20, 19, 1, 8, 'project', 1, FALSE, NULL, '2026-06-19T09:48:30.2050386', 'Project assigned', 'You have been assigned to project ''Yangon Customer Portal'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (21, 2, 1, 8, 'project', 2, FALSE, NULL, '2026-06-19T09:49:18.3612860', 'Project assigned', 'You have been assigned to project ''Mandalay Inventory System'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (22, 4, 1, 8, 'project', 2, FALSE, NULL, '2026-06-19T09:49:18.3612868', 'Project assigned', 'You have been assigned to project ''Mandalay Inventory System'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (23, 12, 1, 8, 'project', 2, TRUE, '2026-07-02T03:38:23.1783888', '2026-06-19T09:49:18.3612871', 'Project assigned', 'You have been assigned to project ''Mandalay Inventory System'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (24, 14, 1, 8, 'project', 2, FALSE, NULL, '2026-06-19T09:49:18.3612873', 'Project assigned', 'You have been assigned to project ''Mandalay Inventory System'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (25, 13, 1, 8, 'project', 2, FALSE, NULL, '2026-06-19T09:49:18.3612875', 'Project assigned', 'You have been assigned to project ''Mandalay Inventory System'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (26, 12, 1, 1, 'task', 9, TRUE, '2026-07-02T03:38:39.9796184', '2026-06-19T09:49:41.7266775', 'Task assigned', 'You have been assigned a new task: Stock dashboard cards'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (27, 12, 1, 8, 'project', 6, TRUE, '2026-07-02T03:38:52.3850103', '2026-06-19T09:51:01.7051302', 'Project assigned', 'You have been assigned to project ''Mawlamyine Logistics Hub'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (28, 12, 1, 1, 'task', 43, TRUE, '2026-07-02T03:38:28.7529662', '2026-06-19T09:51:17.0125764', 'Task assigned', 'You have been assigned a new task: API Integration'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (30, 4, 12, 2, 'task', 43, FALSE, NULL, '2026-06-19T14:25:22.0208429', 'Task status changed', 'Task ''API Integration'' changed from To Do to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (31, 12, 1, 2, 'task', 117, TRUE, '2026-07-02T03:38:33.5733219', '2026-06-20T02:53:07.1422148', 'Task status changed', 'Task ''Critical Security Patch'' changed from To Do to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (32, 6, 1, 2, 'task', 117, FALSE, NULL, '2026-06-20T02:53:07.1423155', 'Task status changed', 'Task ''Critical Security Patch'' changed from To Do to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (33, 4, 1, 2, 'task', 117, FALSE, NULL, '2026-06-20T02:53:07.1423160', 'Task status changed', 'Task ''Critical Security Patch'' changed from To Do to Done'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (34, 19, 1, 2, 'task', 120, FALSE, NULL, '2026-06-22T03:15:36.3428593', 'Task status changed', 'Task ''Supplier Sync Validation'' changed from To Do to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (35, 8, 1, 2, 'task', 120, FALSE, NULL, '2026-06-22T03:15:36.3430321', 'Task status changed', 'Task ''Supplier Sync Validation'' changed from To Do to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (36, 7, 1, 2, 'task', 120, FALSE, NULL, '2026-06-22T03:15:36.3430333', 'Task status changed', 'Task ''Supplier Sync Validation'' changed from To Do to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (37, 2, 1, 8, 'project', 22, TRUE, '2026-06-24T08:09:29.0059447', '2026-06-22T03:55:44.1048049', 'Project assigned', 'You have been assigned to project ''Clothing '''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (41, 6, 1, 5, 'task', 117, FALSE, NULL, '2026-06-22T04:15:52.2196317', 'New comment', 'System Admin commented on task ''Critical Security Patch'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (42, 4, 1, 5, 'task', 117, FALSE, NULL, '2026-06-22T04:15:52.2196320', 'New comment', 'System Admin commented on task ''Critical Security Patch'''); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (43, 6, 12, 2, 'task', 117, FALSE, NULL, '2026-06-24T07:56:12.4574052', 'Task status changed', 'Task ''Critical Security Patch'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (44, 4, 12, 2, 'task', 117, FALSE, NULL, '2026-06-24T07:56:12.4574906', 'Task status changed', 'Task ''Critical Security Patch'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (45, 7, 12, 2, 'task', 41, FALSE, NULL, '2026-06-24T07:56:16.2396720', 'Task status changed', 'Task ''Customer Report '' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (46, 9, 12, 2, 'task', 7, FALSE, NULL, '2026-06-24T07:56:19.9425043', 'Task status changed', 'Task ''Audit trail page'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (47, 4, 12, 2, 'task', 9, FALSE, NULL, '2026-06-24T07:56:23.6521249', 'Task status changed', 'Task ''Stock dashboard cards'' changed from Done to In Progress'); +INSERT INTO "notifications" ("Id", "recipient_id", "sender_id", "notification_type", "source_type", "source_id", "is_read", "read_at", "created_at", "Title", "Body") VALUES (48, 4, 12, 2, 'task', 43, FALSE, NULL, '2026-06-24T07:56:27.8058837', 'Task status changed', 'Task ''API Integration'' changed from Done to In Progress'); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (1, 'Projects_List', 2, 'List', 'api/Project', 'GET', TRUE, 1, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (2, 'Projects_Create', 2, 'Create', 'api/Project', 'POST', TRUE, 2, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (3, 'Projects_Update', 2, 'Update', 'api/Project', 'PUT', TRUE, 3, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (4, 'Projects_Delete', 2, 'Delete', 'api/Project', 'DELETE', TRUE, 4, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (5, 'Tasks_List', 5, 'List', 'api/Task', 'GET', TRUE, 5, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (6, 'Tasks_Create', 5, 'Create', 'api/Task', 'POST', TRUE, 6, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (7, 'Tasks_Update', 5, 'Update', 'api/Task', 'PUT', TRUE, 7, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (8, 'Tasks_Delete', 5, 'Delete', 'api/Task', 'DELETE', TRUE, 8, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (9, 'Users_List', 15, 'List', 'api/User', 'GET', TRUE, 9, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (10, 'Users_Create', 15, 'Create', 'api/User', 'POST', TRUE, 10, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (11, 'Users_Update', 15, 'Update', 'api/User', 'PUT', TRUE, 11, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (12, 'Users_Delete', 15, 'Delete', 'api/User', 'DELETE', TRUE, 12, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (13, 'Roles_List', 16, 'List', 'api/Role', 'GET', TRUE, 13, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (14, 'Roles_Create', 16, 'Create', 'api/Role', 'POST', TRUE, 14, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (15, 'Roles_Update', 16, 'Update', 'api/Role', 'PUT', TRUE, 15, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (16, 'Roles_Delete', 16, 'Delete', 'api/Role', 'DELETE', TRUE, 16, FALSE, 1, '2026-06-01T08:00:00.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (17, 'Issues_List', 22, 'List', 'api/Issue', 'GET', TRUE, 1, FALSE, 1, '2026-07-01T11:00:47.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (18, 'Issues_Create', 22, 'Create', 'api/Issue', 'POST', TRUE, 2, FALSE, 1, '2026-07-01T11:00:47.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (19, 'Issues_Update', 22, 'Update', 'api/Issue', 'PUT', TRUE, 3, FALSE, 1, '2026-07-01T11:00:47.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (20, 'Issues_Delete', 22, 'Delete', 'api/Issue', 'DELETE', TRUE, 4, FALSE, 1, '2026-07-01T11:00:47.0000000', NULL, NULL); +INSERT INTO "Permissions" ("PermissionId", "PermissionCode", "MenuId", "ActionName", "ApiName", "HttpMethod", "Visible", "OrderNo", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (21, 'AuditLogs_List', 24, 'List', 'api/AuditLog', 'GET', TRUE, 28, FALSE, 1, '2026-07-03T07:14:40.0000000', NULL, NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 2, '2026-06-19T09:48:29.983', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 3, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 12, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 13, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 14, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 15, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 16, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 17, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 18, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (1, 19, '2026-06-19T09:48:30.023', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 2, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 3, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 4, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 12, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 13, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 14, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 15, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 16, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (2, 17, '2026-06-19T09:49:18.317', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (3, 4, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (3, 18, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (3, 19, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (3, 20, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (4, 5, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (4, 21, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (4, 22, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (4, 23, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (5, 6, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (5, 24, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (5, 25, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (5, 26, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (6, 7, '2026-06-19T09:51:01.677', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (6, 12, '2026-06-19T09:51:01.677', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (6, 27, '2026-06-19T09:51:01.677', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (6, 28, '2026-06-19T09:51:01.677', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (6, 29, '2026-06-19T09:51:01.677', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (7, 8, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (7, 30, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (7, 31, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (7, 32, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (8, 9, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (8, 33, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (8, 34, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (8, 35, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (9, 10, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (9, 36, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (9, 37, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (9, 38, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (10, 11, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (10, 39, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (10, 40, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (10, 41, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (10, 42, '2026-06-19T14:11:15.377', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (22, 2, '2026-06-22T03:57:53.473', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (22, 12, '2026-06-22T03:57:53.473', NULL); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (23, 1, '2027-01-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (23, 2, '2027-01-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (23, 12, '2027-01-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (23, 13, '2027-01-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (24, 1, '2027-02-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (24, 3, '2027-02-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (24, 14, '2027-02-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (24, 15, '2027-02-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (25, 1, '2027-03-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (25, 4, '2027-03-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (25, 16, '2027-03-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (25, 17, '2027-03-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (26, 1, '2027-04-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (26, 5, '2027-04-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (26, 18, '2027-04-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (26, 19, '2027-04-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (27, 1, '2027-05-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (27, 6, '2027-05-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (27, 20, '2027-05-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (27, 21, '2027-05-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (28, 1, '2027-06-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (28, 7, '2027-06-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (28, 22, '2027-06-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (28, 23, '2027-06-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (29, 1, '2027-07-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (29, 8, '2027-07-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (29, 24, '2027-07-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (29, 25, '2027-07-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (30, 1, '2027-08-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (30, 9, '2027-08-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (30, 26, '2027-08-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (30, 27, '2027-08-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (31, 1, '2027-09-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (31, 10, '2027-09-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (31, 28, '2027-09-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (31, 29, '2027-09-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (32, 1, '2027-10-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (32, 11, '2027-10-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (32, 30, '2027-10-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (32, 31, '2027-10-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (33, 1, '2027-11-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (33, 32, '2027-11-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (33, 33, '2027-11-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (33, 42, '2027-11-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (34, 1, '2027-12-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (34, 2, '2027-12-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (34, 34, '2027-12-01T09:00:00.000', 1); +INSERT INTO "ProjectMembers" ("ProjectId", "UserId", "CreatedAt", "CreatedBy") VALUES (34, 35, '2027-12-01T09:00:00.000', 1); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (1, 'Yangon Customer Portal', 'Customer portal for Yangon branches', '2026-06-01T00:00:00.000', '2026-08-31T00:00:00.000', 1, '2026-06-01T00:00:00.000', '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE, 160); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (2, 'Mandalay Inventory System', 'Warehouse and stock tracking for Mandalay team', '2026-06-03T00:00:00.000', '2026-09-15T00:00:00.000', 1, '2026-06-03T00:00:00.000', '2026-06-03T00:00:00.000', 1, NULL, NULL, FALSE, 220); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (3, 'Nay Pyi Taw HR Tracker', 'HR workflow and leave tracking', '2026-06-05T00:00:00.000', '2026-09-30T00:00:00.000', 1, '2026-06-05T00:00:00.000', '2026-06-05T00:00:00.000', 1, NULL, NULL, FALSE, 180); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (4, 'Bago Procurement Hub', 'Purchase request and approval workflow', '2026-06-07T00:00:00.000', '2026-10-15T00:00:00.000', 1, '2026-06-07T00:00:00.000', '2026-06-07T00:00:00.000', 1, '2026-06-19T13:00:00.000', 1, FALSE, 200); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (5, 'Taunggyi Support Desk', 'Helpdesk and issue tracking dashboard', '2026-06-10T00:00:00.000', '2026-10-31T00:00:00.000', 1, '2026-06-10T00:00:00.000', '2026-06-10T00:00:00.000', 1, '2026-06-19T13:00:00.000', 1, FALSE, 240); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (6, 'Mawlamyine Logistics Hub', 'Supply chain tracking system', '2026-07-01T00:00:00.000', '2026-09-30T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 180); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (7, 'Pathein Seafood Portal', 'B2B marketplace platform', '2026-07-15T00:00:00.000', '2026-11-15T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 250); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (8, 'Monywa Billing System', 'Automated utility billing service', '2026-08-01T00:00:00.000', '2026-12-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 300); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (9, 'Sittwe Port Dashboard', 'Vessel and cargo tracking UI', '2026-09-01T00:00:00.000', '2026-11-30T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 140); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (10, 'Pyin Oo Lwin AgriTech', 'Smart farming analytics dashboard', '2026-10-01T00:00:00.000', '2027-02-28T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 400); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (11, 'Lashio Retail Point', 'Cloud POS system for local shops', '2026-11-01T00:00:00.000', '2027-01-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 120); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (12, 'Dawei Eco-Tourism App', 'Booking and travel guide application', '2026-12-01T00:00:00.000', '2027-04-30T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 350); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (13, 'Hpa-An Enterprise ERP', 'Core resource planning modules', '2027-01-10T00:00:00.000', '2027-06-30T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 500); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (14, 'Magway Solar Grid Tracker', 'Renewable energy monitoring metrics', '2027-02-01T00:00:00.000', '2027-05-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 210); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (15, 'Myitkyina Education Portal', 'E-learning system for state colleges', '2027-03-01T00:00:00.000', '2027-08-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 280); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (16, 'Shwebo Rice Mill Software', 'Inventory and quality control logs', '2027-04-15T00:00:00.000', '2027-07-15T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 160); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (17, 'Kawthaung Marine Data', 'Fishery quotas and mapping UI', '2027-05-01T00:00:00.000', '2027-10-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 450); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (18, 'Kalay Health Tracker', 'Clinic patient management system', '2027-06-01T00:00:00.000', '2027-09-30T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 190); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (19, 'Kyaukme Tea Supply Chain', 'Export logistics management system', '2027-07-01T00:00:00.000', '2027-12-31T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 310); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (20, 'Meiktila Textile Inventory', 'Factory production tracking ledger', '2027-08-15T00:00:00.000', '2028-01-15T00:00:00.000', 1, '2026-06-19T13:54:36.407', '2026-06-19T13:54:36.407', 1, NULL, NULL, FALSE, 220); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (21, 'test', NULL, '2026-06-01T00:00:00.000', '2026-07-19T00:00:00.000', 1, '2026-06-19T19:28:35.490', '2026-06-19T12:58:35.310', 1, NULL, NULL, TRUE, NULL); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (22, 'Clothing ', NULL, '2026-06-22T00:00:00.000', '2026-07-22T00:00:00.000', 1, '2026-06-22T10:25:04.133', '2026-06-22T03:55:04.050', 1, NULL, NULL, FALSE, 7); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (23, 'Yangon Retail Upgrade', 'POS and storefront refresh for Q1', '2027-01-01T00:00:00.000', '2027-03-31T00:00:00.000', 1, '2027-01-01T00:00:00.000', '2027-01-01T09:00:00.000', 1, NULL, NULL, FALSE, 180); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (24, 'Mandalay Service Portal', 'Support portal and SLA workflow for Q1', '2027-02-01T00:00:00.000', '2027-04-30T00:00:00.000', 1, '2027-02-01T00:00:00.000', '2027-02-01T09:00:00.000', 1, NULL, NULL, FALSE, 200); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (25, 'Nay Pyi Taw Payroll Hub', 'Payroll and approvals refresh for Q2', '2027-03-01T00:00:00.000', '2027-05-31T00:00:00.000', 1, '2027-03-01T00:00:00.000', '2027-03-01T09:00:00.000', 1, NULL, NULL, FALSE, 220); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (26, 'Bago Vendor Board', 'Vendor onboarding and approval board', '2027-04-01T00:00:00.000', '2027-06-30T00:00:00.000', 1, '2027-04-01T00:00:00.000', '2027-04-01T09:00:00.000', 1, NULL, NULL, FALSE, 160); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (27, 'Taunggyi Delivery Tracker', 'Delivery scheduling and route updates', '2027-05-01T00:00:00.000', '2027-07-31T00:00:00.000', 1, '2027-05-01T00:00:00.000', '2027-05-01T09:00:00.000', 1, NULL, NULL, FALSE, 240); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (28, 'Mawlamyine Field App', 'Field support mobile app', '2027-06-01T00:00:00.000', '2027-08-31T00:00:00.000', 1, '2027-06-01T00:00:00.000', '2027-06-01T09:00:00.000', 1, NULL, NULL, FALSE, 260); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (29, 'Pathein Sales Dashboard', 'Sales analytics and KPI rollout', '2027-07-01T00:00:00.000', '2027-09-30T00:00:00.000', 1, '2027-07-01T00:00:00.000', '2027-07-01T09:00:00.000', 1, NULL, NULL, FALSE, 210); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (30, 'Monywa Claims System', 'Claims intake and review workflow', '2027-08-01T00:00:00.000', '2027-10-31T00:00:00.000', 1, '2027-08-01T00:00:00.000', '2027-08-01T09:00:00.000', 1, NULL, NULL, FALSE, 230); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (31, 'Sittwe Logistics Control', 'Cargo status and dispatch planning', '2027-09-01T00:00:00.000', '2027-11-30T00:00:00.000', 1, '2027-09-01T00:00:00.000', '2027-09-01T09:00:00.000', 1, NULL, NULL, FALSE, 190); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (32, 'Pyin Oo Lwin Billing Upgrade', 'Billing and statement enhancements', '2027-10-01T00:00:00.000', '2027-12-31T00:00:00.000', 1, '2027-10-01T00:00:00.000', '2027-10-01T09:00:00.000', 1, NULL, NULL, FALSE, 250); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (33, 'Lashio Customer Care', 'Customer care portal and case notes', '2027-11-01T00:00:00.000', '2028-01-31T00:00:00.000', 1, '2027-11-01T00:00:00.000', '2027-11-01T09:00:00.000', 1, NULL, NULL, FALSE, 280); +INSERT INTO "Projects" ("Id", "Name", "Description", "StartDate", "EndDate", "CreatedById", "CreatedDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "BudgetedHours") VALUES (34, 'Dawei Operations Suite', 'Operations planning for year-end launch', '2027-12-01T00:00:00.000', '2028-02-28T00:00:00.000', 1, '2027-12-01T00:00:00.000', '2027-12-01T09:00:00.000', 1, NULL, NULL, FALSE, 300); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (1, 3, 1, TRUE, NULL, '2026-06-17T10:20:39.0000000', 1, '2026-07-01T07:11:50.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (2, 3, 3, FALSE, NULL, '2026-06-17T10:20:39.0000000', 1, '2026-06-18T04:35:14.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (3, 3, 2, FALSE, NULL, '2026-06-17T10:20:39.0000000', 1, '2026-06-18T04:35:14.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (4, 3, 6, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (5, 3, 5, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (6, 3, 10, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (7, 3, 9, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (8, 3, 12, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (9, 3, 14, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (10, 3, 17, TRUE, NULL, '2026-06-17T10:20:39.0000000', 1, '2026-07-02T03:39:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (11, 1, 1, TRUE, NULL, '2026-06-17T14:55:11.0000000', 1, '2026-07-01T06:53:20.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (12, 1, 2, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (13, 1, 3, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (14, 1, 4, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (15, 1, 5, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (16, 1, 6, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (17, 1, 7, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (18, 1, 8, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (19, 1, 9, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (20, 1, 10, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (21, 1, 11, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (22, 1, 12, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (23, 1, 13, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (24, 1, 14, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (25, 1, 15, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (26, 1, 16, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (27, 1, 17, TRUE, NULL, '2026-06-17T14:55:11.0000000', 1, '2026-07-01T08:19:04.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (28, 2, 1, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (29, 2, 2, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (30, 2, 3, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (31, 2, 4, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (32, 2, 5, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (33, 2, 6, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (34, 2, 7, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (35, 2, 8, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (36, 2, 9, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (37, 2, 10, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (38, 2, 11, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (39, 2, 12, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (40, 2, 13, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (41, 2, 14, TRUE, NULL, '2026-06-18T04:05:50.0000000', 1, '2026-06-18T09:04:05.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (42, 2, 15, TRUE, NULL, '2026-06-18T04:24:05.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (43, 2, 16, TRUE, NULL, '2026-06-18T04:24:39.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (44, 2, 17, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (45, 3, 4, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (46, 3, 7, FALSE, 1, '2026-06-18T04:33:33.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (47, 3, 8, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (48, 3, 11, FALSE, 1, '2026-06-18T04:33:33.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (49, 3, 13, FALSE, 1, '2026-06-18T04:33:33.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (50, 3, 15, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (51, 3, 16, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (52, 1, 18, FALSE, NULL, '2026-06-19T04:45:00.0000000', NULL, '2026-06-19T06:48:19.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (53, 1, 19, FALSE, NULL, '2026-06-19T07:44:17.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (54, 2, 20, FALSE, NULL, '2026-06-19T08:05:25.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (55, 3, 21, FALSE, NULL, '2026-06-19T08:05:25.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (56, 5, 2, FALSE, 1, '2026-06-22T04:15:09.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (57, 5, 3, FALSE, 1, '2026-06-22T04:15:09.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (58, 5, 20, FALSE, 1, '2026-06-22T04:15:09.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (59, 1, 22, FALSE, 1, '2026-07-01T06:53:20.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (60, 3, 22, FALSE, 1, '2026-07-01T07:11:50.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (61, 1, 23, FALSE, 1, '2026-07-01T08:19:04.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (62, 3, 23, FALSE, 1, '2026-07-02T03:39:57.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (63, 1, 24, FALSE, 1, '2026-07-03T07:14:40.0000000', NULL, NULL); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (64, 1, 25, FALSE, NULL, '2026-07-03T08:42:57.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (65, 2, 25, FALSE, NULL, '2026-07-03T08:42:57.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RoleMenus" ("RoleMenuId", "RoleId", "MenuId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (66, 3, 25, FALSE, NULL, '2026-07-03T08:42:57.0000000', NULL, '2026-07-06T06:09:57.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (1, 3, 1, FALSE, NULL, '2026-06-17T10:20:39.0000000', 1, '2026-06-18T04:35:14.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (2, 3, 5, FALSE, NULL, '2026-06-17T10:20:39.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (3, 1, 1, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (4, 1, 2, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (5, 1, 3, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (6, 1, 4, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (7, 1, 5, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (8, 1, 6, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (9, 1, 7, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (10, 1, 8, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (11, 1, 9, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (12, 1, 10, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (13, 1, 11, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (14, 1, 12, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (15, 1, 13, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (16, 1, 14, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (17, 1, 15, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (18, 1, 16, FALSE, NULL, '2026-06-17T14:55:11.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (19, 2, 1, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (20, 2, 2, FALSE, NULL, '2026-06-18T04:05:50.0000000', 1, '2026-06-18T07:57:56.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (21, 2, 3, FALSE, NULL, '2026-06-18T04:05:50.0000000', 1, '2026-06-18T07:57:56.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (22, 2, 4, FALSE, NULL, '2026-06-18T04:05:50.0000000', 1, '2026-06-18T07:57:56.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (23, 2, 5, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (24, 2, 6, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (25, 2, 7, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (26, 2, 8, FALSE, NULL, '2026-06-18T04:05:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (27, 2, 9, TRUE, NULL, '2026-06-18T04:24:05.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (28, 2, 13, TRUE, NULL, '2026-06-18T04:24:39.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (29, 2, 10, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (30, 2, 11, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (31, 2, 12, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (32, 2, 14, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (33, 2, 15, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (34, 2, 16, TRUE, 1, '2026-06-18T04:32:46.0000000', 1, '2026-06-18T04:33:21.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (35, 3, 2, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (36, 3, 3, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (37, 3, 4, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (38, 3, 6, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-19T14:22:20.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (39, 3, 7, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-19T14:22:20.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (40, 3, 8, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-19T14:22:20.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (41, 3, 9, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (42, 3, 10, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (43, 3, 11, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (44, 3, 12, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (45, 3, 13, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (46, 3, 14, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (47, 3, 15, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (48, 3, 16, TRUE, 1, '2026-06-18T04:33:33.0000000', 1, '2026-06-18T04:34:00.0000000'); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (49, 5, 1, FALSE, 1, '2026-06-22T04:15:09.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (50, 5, 2, FALSE, 1, '2026-06-22T04:15:09.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (51, 1, 17, FALSE, 1, '2026-07-01T06:53:20.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (52, 1, 18, FALSE, 1, '2026-07-01T06:53:20.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (53, 1, 19, FALSE, 1, '2026-07-01T06:53:20.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (54, 1, 20, FALSE, 1, '2026-07-01T06:53:20.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (55, 3, 17, FALSE, 1, '2026-07-01T07:11:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (56, 3, 18, FALSE, 1, '2026-07-01T07:11:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (57, 3, 19, FALSE, 1, '2026-07-01T07:11:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (58, 3, 20, FALSE, 1, '2026-07-01T07:11:50.0000000', NULL, NULL); +INSERT INTO "RolePermissions" ("RolePermissionId", "RoleId", "PermissionId", "IsDeleted", "CreatedById", "CreatedAt", "UpdatedById", "UpdatedAt") VALUES (59, 1, 21, FALSE, 1, '2026-07-03T07:14:40.0000000', 1, '2026-07-03T07:15:14.0000000'); +INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (1, 'Admin', 'Full system access', '2026-06-01T00:00:00.000', NULL, NULL, NULL, FALSE); +INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (2, 'Manager', 'Project-scoped manager', '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (3, 'Employee', 'Task-level team member', '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (4, 'test', NULL, '2026-06-19T13:02:05.897', 1, '2026-06-19T13:06:51.477', 1, TRUE); +INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (5, 'Supervisor', NULL, '2026-06-22T04:15:08.373', 1, NULL, NULL, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (1, 'Portal landing page', 'Landing page cleanup for Yangon Customer Portal', 1, 3, 2, 14, 5, '2026-06-19T00:00:00.000', '2026-06-01T00:00:00.000', 8, '2026-06-18T12:33:55.640', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (2, 'Login and role routing', 'Login flow and redirect rules', 1, 3, 3, 14, 4, '2026-06-14T00:00:00.000', '2026-06-01T00:00:00.000', 8, '2026-06-18T12:45:53.723', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (3, 'Customer profile form', 'Profile edit and validation', 1, 3, 2, 14, 10, '2026-06-18T00:00:00.000', '2026-06-01T00:00:00.000', 2, '2026-06-17T00:00:00.000', 14, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (4, 'Payments view cleanup', 'UI and filter cleanup for payment section', 1, 3, 3, 15, 3, '2026-06-23T00:00:00.000', '2026-06-01T00:00:00.000', 6, '2026-06-17T08:28:18.077', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (5, 'Notification center', 'Notification list and unread badge', 1, 2, 2, 16, 2, '2026-06-28T00:00:00.000', '2026-06-01T00:00:00.000', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (6, 'Export customer list', 'CSV / Excel export for customer data', 1, 1, 1, 17, 8, '2026-07-05T00:00:00.000', '2026-06-01T00:00:00.000', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (7, 'Audit trail page', 'Activity log and history page', 1, 2, 2, 12, 9, '2026-07-08T00:00:00.000', '2026-06-01T00:00:00.000', 9, '2026-06-24T07:56:19.923', 12, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (8, 'Theme polish', 'Final spacing and color polish', 1, 1, 1, 13, 5, '2026-07-12T00:00:00.000', '2026-06-01T00:00:00.000', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (9, 'Stock dashboard cards', 'Warehouse summary cards and counters', 2, 2, 2, 12, NULL, '2026-06-16T00:00:00.000', '2026-06-03T00:00:00.000', 4, '2026-06-24T07:56:23.640', 12, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (10, 'Warehouse item search', 'Fast search and filter for stock items', 2, 3, 2, 19, 4, '2026-06-18T00:00:00.000', '2026-06-03T00:00:00.000', 4, '2026-06-18T00:00:00.000', 19, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (11, 'Low stock alert setup', 'Warning badges for low stock levels', 2, 2, 3, 20, 5, '2026-06-21T00:00:00.000', '2026-06-03T00:00:00.000', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (12, 'Goods receiving form', 'Receive stock entry form', 2, 3, 2, 21, 2, '2026-06-25T00:00:00.000', '2026-06-03T00:00:00.000', 4, '2026-06-24T00:00:00.000', 21, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (13, 'Item barcode page', 'Barcode lookup and item details screen', 2, 2, 1, 22, 6, '2026-07-01T00:00:00.000', '2026-06-03T00:00:00.000', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (14, 'Inventory report export', 'Export stock summary for management', 2, 1, 2, 23, 10, '2026-07-04T00:00:00.000', '2026-06-03T00:00:00.000', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (15, 'Stock correction audit', 'Review adjustment history', 2, 2, 3, 18, 11, '2026-07-09T00:00:00.000', '2026-06-03T00:00:00.000', 4, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (16, 'Supplier mapping', 'Link suppliers to inventory items', 2, 1, 2, 19, 3, '2026-07-15T00:00:00.000', '2026-06-03T00:00:00.000', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (17, 'Employee directory sync', 'Sync employee directory and roles', 3, 3, 2, 24, 7, '2026-06-17T00:00:00.000', '2026-06-05T00:00:00.000', 2, '2026-06-16T00:00:00.000', 24, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (18, 'Leave request workflow', 'Leave request and approval steps', 3, 3, 3, 25, 6, '2026-06-20T00:00:00.000', '2026-06-05T00:00:00.000', 7, '2026-06-19T00:00:00.000', 25, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (19, 'Attendance summary', 'Daily attendance and monthly summary', 3, 2, 2, 26, 7, '2026-06-24T00:00:00.000', '2026-06-05T00:00:00.000', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (20, 'Holiday calendar setup', 'Company holiday calendar maintenance', 3, 3, 1, 27, 4, '2026-06-29T00:00:00.000', '2026-06-05T00:00:00.000', 5, '2026-06-29T00:00:00.000', 27, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (21, 'Department assignment', 'Department mapping for employee records', 3, 2, 2, 28, 2, '2026-07-03T00:00:00.000', '2026-06-05T00:00:00.000', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (22, 'Payroll export cleanup', 'Prepare payroll export format', 3, 1, 3, 29, 10, '2026-07-08T00:00:00.000', '2026-06-05T00:00:00.000', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (23, 'Manager approval panel', 'Approval screen for managers', 3, 2, 2, 24, 2, '2026-07-11T00:00:00.000', '2026-06-05T00:00:00.000', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (24, 'Employee dashboard polish', 'Visual cleanup for employee view', 3, 1, 1, 25, 3, '2026-07-18T00:00:00.000', '2026-06-05T00:00:00.000', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (25, 'Purchase request list', 'Procurement request list and status', 4, 3, 2, 30, 7, '2026-06-19T00:00:00.000', '2026-06-07T00:00:00.000', 3, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (26, 'Vendor lookup screen', 'Vendor search and details page', 4, 3, 2, 31, 2, '2026-06-22T00:00:00.000', '2026-06-07T00:00:00.000', 3, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (27, 'Approval chain setup', 'Approval levels and routing rules', 4, 2, 3, 32, 8, '2026-06-26T00:00:00.000', '2026-06-07T00:00:00.000', 9, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (28, 'Budget warning banner', 'Banner when purchase exceeds budget', 4, 3, 2, 33, 8, '2026-07-01T00:00:00.000', '2026-06-07T00:00:00.000', 11, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (29, 'Procurement summary report', 'Summary page for approvals and budgets', 4, 2, 1, 34, 7, '2026-07-06T00:00:00.000', '2026-06-07T00:00:00.000', 2, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (30, 'Attachment viewer', 'Preview uploaded purchase attachments', 4, 1, 2, 35, 2, '2026-07-10T00:00:00.000', '2026-06-07T00:00:00.000', 11, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (31, 'Request aging status', 'Age status indicator for procurement requests', 4, 2, 3, 30, 7, '2026-07-14T00:00:00.000', '2026-06-07T00:00:00.000', 4, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (32, 'Approval comments', 'Comment thread in approval steps', 4, 1, 2, 31, 4, '2026-07-20T00:00:00.000', '2026-06-07T00:00:00.000', 7, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (33, 'Ticket queue board', 'Kanban-style support queue board', 5, 3, 2, 36, 9, '2026-06-21T00:00:00.000', '2026-06-10T00:00:00.000', 10, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (34, 'Priority color rules', 'Priority badges and board colors', 5, 3, 3, 37, 3, '2026-06-24T00:00:00.000', '2026-06-10T00:00:00.000', 8, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (35, 'Assignee quick filter', 'Quick filter by assignee', 5, 2, 2, 38, 3, '2026-06-28T00:00:00.000', '2026-06-10T00:00:00.000', 3, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (36, 'Overdue banner', 'Highlight overdue tickets clearly', 5, 3, 2, 39, 2, '2026-07-02T00:00:00.000', '2026-06-10T00:00:00.000', 8, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (37, 'Customer comment log', 'Log of customer replies and notes', 5, 2, 1, 40, 9, '2026-07-07T00:00:00.000', '2026-06-10T00:00:00.000', 5, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (38, 'FAQ side panel', 'Helpful FAQ panel in support screen', 5, 1, 2, 41, 9, '2026-07-11T00:00:00.000', '2026-06-10T00:00:00.000', 11, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (39, 'SLA summary chart', 'Support SLA summary and chart', 5, 2, 3, 36, 11, '2026-07-16T00:00:00.000', '2026-06-10T00:00:00.000', 4, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (40, 'Notification templates', 'Template library for support notifications', 5, 1, 2, 37, 9, '2026-07-22T00:00:00.000', '2026-06-10T00:00:00.000', 9, '2026-06-19T13:30:00.000', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (41, 'Customer Report ', 'adjust customer report', 1, 2, 2, 12, 7, '2026-06-23T00:00:00.000', '2026-06-16T09:35:34.917', 7, '2026-06-24T07:56:16.223', 12, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (42, 'Database Schema Setup', 'Design tables for supply chain entities', 6, 3, 3, 23, 7, '2026-07-15T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (43, 'API Integration', 'Connect tracking endpoints with vendor APIs', 6, 2, 2, 12, NULL, '2026-08-10T00:00:00.000', '2026-06-19T13:59:06.027', 4, '2026-06-24T07:56:27.793', 12, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (44, 'Route Optimization UI', 'Build map visualization dashboard', 6, 1, 2, 12, NULL, '2026-08-30T00:00:00.000', '2026-06-19T13:59:06.027', 4, '2026-06-19T09:51:36.570', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (45, 'Driver Manifest Report', 'Export layout formatting for logistics crews', 6, 2, 1, 25, 7, '2026-09-15T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (46, 'UAT Signoff', 'Final validation with regional operations lead', 6, 2, 3, 26, 3, '2026-09-28T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (47, 'Vendor Registration Form', 'Workflow setup for verified seafood distributors', 7, 3, 2, 27, 9, '2026-08-01T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (48, 'Product Catalog Grid', 'Display live catch categories and listings', 7, 3, 2, 28, 2, '2026-08-25T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (49, 'Bidding Engine Rules', 'Real-time price updating logic engine', 7, 1, 3, 29, 8, '2026-09-20T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (50, 'Escrow Payment Gateway', 'Stripe/Local banking payment integration', 7, 1, 3, 24, 9, '2026-10-15T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (51, 'Mobile Responsive Layout', 'Ensure smooth formatting across Android tablets', 7, 2, 1, 25, 11, '2026-11-05T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (52, 'Meter Reading Sync API', 'Job runner to pull consumption logs', 8, 3, 3, 30, 9, '2026-08-20T00:00:00.000', '2026-06-19T13:59:06.027', 4, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (53, 'Invoice PDF Engine', 'Design standardized print layouts', 8, 3, 2, 31, 7, '2026-09-15T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (54, 'Overdue Alert System', 'SMS notifications for pending payments', 8, 1, 2, 32, 10, '2026-10-25T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (55, 'Tariff Calculator Logic', 'Multi-tiered processing script based on units used', 8, 1, 3, 33, 11, '2026-11-30T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (56, 'Audit Trail Log View', 'Admin UI screen tracking custom manual edits', 8, 2, 1, 34, 5, '2026-12-15T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (57, 'Vessel Docking Scheduler', 'Interactive planning board view for incoming ships', 9, 3, 3, 35, 7, '2026-09-20T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (58, 'Manifest Data Parser', 'Excel/CSV customs document importer logic', 9, 3, 2, 30, 7, '2026-10-05T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (59, 'Live Berth Map Graphic', 'SVG tracking layout of structural ship docks', 9, 1, 2, 31, 10, '2026-10-25T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (60, 'Customs Status Badges', 'Color indicators for tax clear/hold logs', 9, 1, 1, 36, 9, '2026-11-10T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (61, 'Daily Tonnage Aggregator', 'High-performance SQL reporting query', 9, 2, 2, 37, 2, '2026-11-25T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (62, 'IoT Sensor Connector', 'Telemetry listeners for ground moisture grids', 10, 3, 3, 38, 9, '2026-11-01T00:00:00.000', '2026-06-19T13:59:06.027', 4, '2026-06-22T02:45:23.530', 1, FALSE, TRUE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (63, 'Weather Analytics Forecast', 'Third-party API telemetry feed cards', 10, 3, 1, 39, 5, '2026-12-01T00:00:00.000', '2026-06-19T13:59:06.027', 6, '2026-06-22T02:45:23.530', 1, FALSE, TRUE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (64, 'Crop Cycle Milestones', 'Visual step tracker for flower and vegetable blocks', 10, 1, 2, 40, 6, '2027-01-10T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (65, 'Yield Prediction Model', 'Chart projections for harvest season capacities', 10, 1, 2, 41, 7, '2027-02-05T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (66, 'Alert Threshold Adjustments', 'Sliders for minimal resource alarm criteria', 10, 2, 3, 36, 4, '2027-02-20T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (67, 'Local Storage Fallback', 'Caching logic if internet connection drops', 11, 3, 3, 37, 6, '2026-11-20T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (68, 'Barcode Scanner Support', 'Input text pattern handler logic updates', 11, 3, 2, 38, 7, '2026-12-10T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (69, 'Receipt Component Design', 'Thermal printer formatting view engine', 11, 1, 2, 39, 5, '2026-12-28T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (70, 'Shift Drawer Checkout', 'Cash drawer closing calculations view', 11, 1, 2, 40, 11, '2027-01-15T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (71, 'Discount Code Engine', 'Flexible rules for flat rate percentage deductions', 11, 2, 1, 41, 5, '2027-01-25T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (72, 'Hotel Availability Calendars', 'Dynamic availability layout blocks', 12, 3, 3, 23, 10, '2027-01-05T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (73, 'Tour Guide Matrix Profiles', 'Rating breakdowns and skill badge designs', 12, 3, 1, 24, 3, '2027-02-01T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (74, 'Multi-Currency Selector', 'Automatic exchange rate conversion cards', 12, 1, 2, 19, 7, '2027-03-01T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (75, 'Offline Map Pack Downloads', 'Asset compression handlers for remote areas', 12, 1, 3, 25, 2, '2027-03-25T00:00:00.000', '2026-06-19T13:59:06.027', 4, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (76, 'Reviews Slider Feedback', 'Submit forms with media image attachments', 12, 2, 2, 26, 2, '2027-04-15T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (77, 'Org Chart Matrix Engine', 'Interactive recursive reporting layouts', 13, 3, 2, 27, 4, '2027-02-10T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (78, 'Asset Lifetime Depreciation', 'Financial calculations handling ledger variables', 13, 3, 3, 28, 10, '2027-03-15T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (79, 'Purchase Order Flow Approvals', 'Dynamic multi-manager routing filters', 13, 1, 3, 29, 8, '2027-04-30T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (80, 'Inventory Reconciliation Module', 'Stock discrepancy evaluation reports', 13, 1, 2, 24, 9, '2027-05-20T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (81, 'SAML SSO Configuration', 'Corporate secure profile directory linkages', 13, 2, 3, 25, 7, '2027-06-15T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (82, 'Inverter Diagnostic Parsing', 'Error code streaming parser functions', 14, 3, 3, 30, 6, '2027-02-25T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (83, 'KWh Generation Linecharts', 'Time series streaming metrics configurations', 14, 3, 2, 31, 2, '2027-03-20T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (84, 'Battery Degradation Alerter', 'Proactive email dispatcher rules criteria', 14, 1, 3, 32, 11, '2027-04-10T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (85, 'Maintenance Dispatch Planner', 'Assign technicians based on geolocation proximity', 14, 1, 2, 33, 11, '2027-05-05T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (86, 'Weather Impact Forecasting', 'Overlay expected overcast cloud calculations', 14, 2, 1, 34, 5, '2027-05-25T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (87, 'Student Enrollment Grid', 'Batch processing CSV processing views', 15, 3, 2, 35, 3, '2027-04-01T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (88, 'Quiz Generator Panel', 'Form wizard builder tool configuration', 15, 3, 2, 30, 3, '2027-05-15T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (89, 'Video Lecture Player Wrapper', 'Custom video player UI component controls', 15, 1, 3, 31, 6, '2027-06-20T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (90, 'Grading Rubric Calculations', 'Weighted score aggregate math logic functions', 15, 1, 3, 36, 7, '2027-07-25T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (91, 'Certificate Generation System', 'Automated PDF generation with secure signatures', 15, 2, 1, 37, 9, '2027-08-18T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (92, 'Moisture Content Checklist', 'Intake quality verification forms', 16, 3, 3, 38, 3, '2027-05-01T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (93, 'Silo Storage Tracking', 'Volumetric storage level visual component', 16, 3, 2, 39, 2, '2027-05-25T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (94, 'Wholesale Pricing Sheets', 'Bulk pricing calculation configurations', 16, 1, 2, 40, 8, '2027-06-15T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (95, 'Batch Shipments Monitor', 'Fulfillment workflow validation checks', 16, 1, 3, 41, 9, '2027-07-02T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (96, 'Scale Calibration Log', 'Record periodic equipment verification histories', 16, 2, 1, 36, 4, '2027-07-12T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (97, 'Vessel GPS Coordinates Stream', 'Leaflet map vector visualization layer integrations', 17, 3, 3, 37, 10, '2027-06-01T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (98, 'Species Harvest Trackers', 'Data breakdowns for compliance regulatory reports', 17, 3, 2, 38, 5, '2027-07-15T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (99, 'Zone Restrictive Parameters', 'Polygonal boundary intersection check alarms', 17, 1, 3, 39, 2, '2027-08-30T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (100, 'Seasonal Quota Counter Gauges', 'Radial dynamic bar indicator graphics', 17, 1, 2, 40, 4, '2027-09-25T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (101, 'Patrol Crew Assignment UI', 'Shift scheduler dispatch interface layouts', 17, 2, 1, 41, 9, '2027-10-15T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (102, 'Patient Registration Wizard', 'Multi-step demographics entry card setup', 18, 3, 2, 23, 11, '2027-06-20T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (103, 'Vitals Stream Intake', 'Fields mapping pulse, temperature, and blood pressure', 18, 3, 3, 24, 11, '2027-07-15T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (104, 'Prescription Text Composer', 'Autocomplete pharmaceutical database connectors', 18, 1, 3, 19, 8, '2027-08-10T00:00:00.000', '2026-06-19T13:59:06.027', 8, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (105, 'Appointment Waitlist Logic', 'FIFO triage processing queue mechanics', 18, 1, 2, 25, 8, '2027-09-05T00:00:00.000', '2026-06-19T13:59:06.027', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (106, 'Lab Result File Attachment', 'Secure PDF scan upload components', 18, 2, 1, 26, 2, '2027-09-25T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (107, 'Grower Cooperative Listing', 'Directory views showing regional sourcing partners', 19, 3, 2, 27, 7, '2027-08-01T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (108, 'Drying Batch Quality Logs', 'Grade checks logging humidity variables', 19, 3, 2, 28, 8, '2027-09-15T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (109, 'Container Packing Manifests', 'CBM cube optimization distribution matrices', 19, 1, 3, 29, 2, '2027-11-01T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (110, 'Customs Duty Adjuster', 'Tax profile calculations based on region rules', 19, 1, 2, 24, 7, '2027-11-30T00:00:00.000', '2026-06-19T13:59:06.027', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (111, 'Barcode Logistics Signoff', 'Mobile handheld warehouse terminal interfaces', 19, 2, 1, 25, 2, '2027-12-20T00:00:00.000', '2026-06-19T13:59:06.027', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (112, 'Yarn Consumable Trackers', 'Color dyeing stock count balancing scripts', 20, 3, 3, 30, 7, '2027-09-05T00:00:00.000', '2026-06-19T13:59:06.027', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (113, 'Loom Capacity Management', 'Operational calendar overview screens for factory equipment', 20, 3, 2, 31, 11, '2027-10-15T00:00:00.000', '2026-06-19T13:59:06.027', 5, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (114, 'Defect Frequency Pareto Chart', 'Aggregated reporting tables for quality audits', 20, 1, 2, 32, 2, '2027-11-20T00:00:00.000', '2026-06-19T13:59:06.027', 7, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (115, 'B2B Client Ordering Portal', 'Direct wholesale submission forms matrix', 20, 1, 3, 33, 7, '2027-12-15T00:00:00.000', '2026-06-19T13:59:06.027', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (116, 'Shipping Invoice Deserializer', 'Automated packing breakdown logs generation', 20, 2, 1, 34, 7, '2028-01-08T00:00:00.000', '2026-06-19T13:59:06.027', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (117, 'Critical Security Patch', 'Apply vulnerability updates to auth endpoints', 1, 2, 3, 12, 6, '2026-06-10T17:00:00.000', '2026-06-19T19:53:49.880', 4, '2026-06-24T07:56:12.210', 12, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (118, 'Database Migration Backup Check', 'Verify integrity logs for early June snapshots', 1, 2, 2, 13, 11, '2026-06-12T12:00:00.000', '2026-06-19T19:53:49.880', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (119, 'Barcode Scanning Performance Fix', 'Optimize UI delay when processing consecutive items', 2, 2, 3, 18, 9, '2026-06-14T18:30:00.000', '2026-06-19T19:53:49.880', 11, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (120, 'Supplier Sync Validation', 'Investigate broken field parameters during nightly cron job', 2, 2, 2, 19, 8, '2026-06-15T09:00:00.000', '2026-06-19T19:53:49.880', 7, '2026-06-22T03:15:36.007', 1, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (121, 'Leave Policy Matrix Update', 'Adjust structural logic for regional holiday tracking formulas', 3, 1, 2, 24, 11, '2026-06-16T23:59:00.000', '2026-06-19T19:53:49.880', 2, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (122, 'Manager Dashboard Loading Delay', 'Optimize missing indexing queries causing timeout flags', 3, 2, 3, 25, 3, '2026-06-17T16:00:00.000', '2026-06-19T19:53:49.880', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (123, 'API Route Finalization', 'Merge staging gateway links into production controller', 1, 2, 2, 14, 2, '2026-06-20T17:00:00.000', '2026-06-19T19:53:49.880', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (124, 'Stock Variance Report UI', 'Format front-end data tables to highlight asset deficits', 2, 2, 2, 15, 6, '2026-06-21T12:00:00.000', '2026-06-19T19:53:49.880', 3, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (125, 'Warehouse Bin Mapping Card', 'Complete dynamic grid visualization view properties', 2, 2, 1, 16, 8, '2026-06-21T17:00:00.000', '2026-06-19T19:53:49.880', 9, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (126, 'Employee Directory Export Button', 'Implement excel/CSV layout downloads for HR staff', 3, 2, 2, 26, 8, '2026-06-22T15:30:00.000', '2026-06-19T19:53:49.880', 6, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (127, 'Document Upload Middleware', 'Enforce strict structural MIME-type verification controls', 3, 2, 3, 27, 8, '2026-06-22T18:00:00.000', '2026-06-19T19:53:49.880', 10, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (128, 'caculate price', NULL, 22, 1, 2, 12, NULL, '2026-06-29T00:00:00.000', '2026-06-22T04:01:27.747', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (129, 'Conduct requirements workshop for checkout flow', 'POS and storefront refresh for Q1 - Collect change requests and confirm scope', 23, 1, 2, 12, 1, '2027-01-11T00:00:00.000', '2027-01-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (130, 'Implement core POS storefront integration', 'POS and storefront refresh for Q1 - Implement the core screens and services', 23, 2, 3, 13, 1, '2027-01-18T00:00:00.000', '2027-01-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (131, 'Validate multi-currency transaction processing', 'POS and storefront refresh for Q1 - Verify edge cases and prep release notes', 23, 3, 1, 12, 1, '2027-01-25T00:00:00.000', '2027-01-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (132, 'Draft customer dashboard SLA requirements', 'Support portal and SLA workflow for Q1 - Collect change requests and confirm scope', 24, 1, 2, 14, 1, '2027-02-11T00:00:00.000', '2027-02-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (133, 'Develop service ticket submission interface', 'Support portal and SLA workflow for Q1 - Implement the core screens and services', 24, 2, 3, 15, 1, '2027-02-18T00:00:00.000', '2027-02-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (134, 'Run UAT billing/routing verification flows', 'Support portal and SLA workflow for Q1 - Verify edge cases and prep release notes', 24, 3, 1, 14, 1, '2027-02-25T00:00:00.000', '2027-02-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (135, 'Review statutory tax calculation rules', 'Payroll and approvals refresh for Q2 - Collect change requests and confirm scope', 25, 1, 2, 16, 1, '2027-03-11T00:00:00.000', '2027-03-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (136, 'Build automated monthly processing engine', 'Payroll and approvals refresh for Q2 - Implement the core screens and services', 25, 2, 3, 17, 1, '2027-03-18T00:00:00.000', '2027-03-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (137, 'Verify payslip PDF layout and data accuracy', 'Payroll and approvals refresh for Q2 - Verify edge cases and prep release notes', 25, 3, 1, 16, 1, '2027-03-25T00:00:00.000', '2027-03-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (138, 'Define vendor onboarding API schemas', 'Vendor onboarding and approval board - Collect change requests and confirm scope', 26, 1, 2, 18, 1, '2027-04-11T00:00:00.000', '2027-04-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (139, 'Create registration wizard and upload UI', 'Vendor onboarding and approval board - Implement the core screens and services', 26, 2, 3, 19, 1, '2027-04-18T00:00:00.000', '2027-04-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (140, 'Audit security compliance on document storage', 'Vendor onboarding and approval board - Verify edge cases and prep release notes', 26, 3, 1, 18, 1, '2027-04-25T00:00:00.000', '2027-04-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (141, 'Analyze real-time GPS location tracking constraints', 'Delivery scheduling and route updates - Collect change requests and confirm scope', 27, 1, 2, 20, 1, '2027-05-11T00:00:00.000', '2027-05-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (142, 'Integrate map APIs and background polling service', 'Delivery scheduling and route updates - Implement the core screens and services', 27, 2, 3, 21, 1, '2027-05-18T00:00:00.000', '2027-05-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (143, 'Test edge cases for background app termination', 'Delivery scheduling and route updates - Verify edge cases and prep release notes', 27, 3, 1, 20, 1, '2027-05-25T00:00:00.000', '2027-05-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (144, 'Document offline data synchronization strategy', 'Field support mobile app - Collect change requests and confirm scope', 28, 1, 2, 22, 1, '2027-06-11T00:00:00.000', '2027-06-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (145, 'Implement local SQLite cache and sync queue', 'Field support mobile app - Implement the core screens and services', 28, 2, 3, 23, 1, '2027-06-18T00:00:00.000', '2027-06-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (146, 'Perform network reconnection conflict resolution tests', 'Field support mobile app - Verify edge cases and prep release notes', 28, 3, 1, 22, 1, '2027-06-25T00:00:00.000', '2027-06-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (147, 'Finalize KPI reporting and visualization metrics', 'Sales analytics and KPI rollout - Collect change requests and confirm scope', 29, 1, 2, 24, 1, '2027-07-11T00:00:00.000', '2027-07-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (148, 'Build aggregate sales APIs with caching layer', 'Sales analytics and KPI rollout - Implement the core screens and services', 29, 2, 3, 25, 1, '2027-07-18T00:00:00.000', '2027-07-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (149, 'Conduct cross-browser and responsiveness check', 'Sales analytics and KPI rollout - Verify edge cases and prep release notes', 29, 3, 1, 24, 1, '2027-07-25T00:00:00.000', '2027-07-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (150, 'Map workflow state rules and approval matrix', 'Claims intake and review workflow - Collect change requests and confirm scope', 30, 1, 2, 26, 1, '2027-08-11T00:00:00.000', '2027-08-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (151, 'Develop automated rules engine for intake evaluation', 'Claims intake and review workflow - Implement the core screens and services', 30, 2, 3, 27, 1, '2027-08-18T00:00:00.000', '2027-08-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (152, 'Verify edge cases on notification routing rules', 'Claims intake and review workflow - Verify edge cases and prep release notes', 30, 3, 1, 26, 1, '2027-08-25T00:00:00.000', '2027-08-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (153, 'Gather warehouse bin mapping and allocation workflows', 'Cargo status and dispatch planning - Collect change requests and confirm scope', 31, 1, 2, 28, 1, '2027-09-11T00:00:00.000', '2027-09-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (154, 'Implement hardware barcode scanner API support', 'Cargo status and dispatch planning - Implement the core screens and services', 31, 2, 3, 29, 1, '2027-09-18T00:00:00.000', '2027-09-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (155, 'Run high-volume simulation stress tests', 'Cargo status and dispatch planning - Verify edge cases and prep release notes', 31, 3, 1, 28, 1, '2027-09-25T00:00:00.000', '2027-09-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (156, 'Review subscription proration and refund logic', 'Billing and statement enhancements - Collect change requests and confirm scope', 32, 1, 2, 30, 1, '2027-10-11T00:00:00.000', '2027-10-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (157, 'Integrate recurring invoice gateway endpoints', 'Billing and statement enhancements - Implement the core screens and services', 32, 2, 3, 31, 1, '2027-10-18T00:00:00.000', '2027-10-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (158, 'Validate automated payment retry dunning loops', 'Billing and statement enhancements - Verify edge cases and prep release notes', 32, 3, 1, 30, 1, '2027-10-25T00:00:00.000', '2027-10-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (159, 'Scope chat widget SLA handling rules', 'Customer care portal and case notes - Collect change requests and confirm scope', 33, 1, 2, 32, 1, '2027-11-11T00:00:00.000', '2027-11-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (160, 'Embed live chat components into client portals', 'Customer care portal and case notes - Implement the core screens and services', 33, 2, 3, 33, 1, '2027-11-18T00:00:00.000', '2027-11-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (161, 'Audit chat transcript persistence and storage cleanup', 'Customer care portal and case notes - Verify edge cases and prep release notes', 33, 3, 1, 32, 1, '2027-11-25T00:00:00.000', '2027-11-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (162, 'Assess resource allocation timeline requirements', 'Operations planning for year-end launch - Collect change requests and confirm scope', 34, 1, 2, 34, 1, '2027-12-11T00:00:00.000', '2027-12-02T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (163, 'Develop interactive scheduling Gantt chart component', 'Operations planning for year-end launch - Implement the core screens and services', 34, 2, 3, 35, 1, '2027-12-18T00:00:00.000', '2027-12-03T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Tasks" ("Id", "Title", "Description", "ProjectId", "StatusId", "PriorityId", "AssignedTo", "AssignedBy", "DueDate", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted", "IsArchived") VALUES (164, 'Verify event triggers on drag-and-drop actions', 'Operations planning for year-end launch - Verify edge cases and prep release notes', 34, 3, 1, 34, 1, '2027-12-25T00:00:00.000', '2027-12-04T00:00:00.000', 1, NULL, NULL, FALSE, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (1, 'admin', 'System', 'Admin', 'admin@tts.local', 'AQAAAAIAAYagAAAAECvk1uHPJiU8Uh+ZE5Gu8KnSE2sfPzF/gWE6WIPftNBpBTE+xeWUsNZJ74vj/WUiQA==', '09111111111', 1, TRUE, '2026-06-01T00:00:00.000', NULL, '2026-06-19T15:46:13.323', NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (2, 'mgr01', 'Kyaw Kyaw', 'Aung', 'mgr01@tts.local', 'AQAAAAIAAYagAAAAEOgN6MzeoxlSLef4b6w5eQ9EO4Lsllm8iMXbM7Nt/e2vJEeSfadZKOHI4wg3OmWlcQ==', '09200000002', 2, TRUE, '2026-06-01T00:00:00.000', 1, '2026-06-16T09:32:15.587', NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (3, 'mgr02', 'Min Min', 'Htun', 'mgr02@tts.local', 'HASHED_P@ssw0rd123!', '09200000003', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (4, 'mgr03', 'Mg Mg', 'Naing', 'mgr03@tts.local', 'HASHED_P@ssw0rd123!', '09200000004', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (5, 'mgr04', 'Aung Aung', 'Oo', 'mgr04@tts.local', 'HASHED_P@ssw0rd123!', '09200000005', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (6, 'mgr05', 'Wai Yan', 'Tun', 'mgr05@tts.local', 'HASHED_P@ssw0rd123!', '09200000006', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (7, 'mgr06', 'Thura', 'Ko', 'mgr06@tts.local', 'HASHED_P@ssw0rd123!', '09200000007', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (8, 'mgr07', 'Su Su', 'Win', 'mgr07@tts.local', 'HASHED_P@ssw0rd123!', '09200000008', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (9, 'mgr08', 'Nandar', 'Hlaing', 'mgr08@tts.local', 'HASHED_P@ssw0rd123!', '09200000009', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (10, 'mgr09', 'Phyo Phyo', 'Aye', 'mgr09@tts.local', 'HASHED_P@ssw0rd123!', '09200000010', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (11, 'mgr10', 'Zaw Zaw', 'Lin', 'mgr10@tts.local', 'HASHED_P@ssw0rd123!', '09200000011', 2, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (12, 'emp01', 'Hla Hla', 'Khin', 'emp01@tts.local', 'AQAAAAIAAYagAAAAEA4f098+mKmL/CQTOuKH1CkfPiJwPEhy6bxxzVH4EUVTSuowjzKm5K9y0qI0bELQ8Q==', '09300000012', 3, TRUE, '2026-06-01T00:00:00.000', 1, '2026-06-17T03:48:14.057', NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (13, 'emp02', 'Nwe Nwe', 'Aye', 'emp02@tts.local', 'HASHED_P@ssw0rd123!', '09300000013', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (14, 'emp03', 'Ei Ei', 'Mon', 'emp03@tts.local', 'AQAAAAIAAYagAAAAELrpxwuFpgj6b0nqE/Y52BOgkPLe+gZi31D68B9JqrlJu442YGtwS+HySwi2WFV+UA==', '09300000014', 3, TRUE, '2026-06-01T00:00:00.000', 1, '2026-06-18T15:49:29.023', NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (15, 'emp04', 'Thandar', 'Win', 'emp04@tts.local', 'HASHED_P@ssw0rd123!', '09300000015', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (16, 'emp05', 'Khin Khin', 'Tun', 'emp05@tts.local', 'HASHED_P@ssw0rd123!', '09300000016', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (17, 'emp06', 'Su Mon', 'Hlaing', 'emp06@tts.local', 'HASHED_P@ssw0rd123!', '09300000017', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (18, 'emp07', 'Mon Mon', 'Aung', 'emp07@tts.local', 'HASHED_P@ssw0rd123!', '09300000018', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (19, 'emp08', 'Yee Yee', 'Lwin', 'emp08@tts.local', 'HASHED_P@ssw0rd123!', '09300000019', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (20, 'emp09', 'Pyae Pyae', 'Naing', 'emp09@tts.local', 'HASHED_P@ssw0rd123!', '09300000020', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (21, 'emp10', 'May', 'Zaw', 'emp10@tts.local', 'HASHED_P@ssw0rd123!', '09300000021', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (22, 'emp11', 'Htet Htet', 'Oo', 'emp11@tts.local', 'HASHED_P@ssw0rd123!', '09300000022', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (23, 'emp12', 'Aye Aye', 'Khaing', 'emp12@tts.local', 'HASHED_P@ssw0rd123!', '09300000023', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (24, 'emp13', 'Chaw Chaw', 'Aung', 'emp13@tts.local', 'HASHED_P@ssw0rd123!', '09300000024', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (25, 'emp14', 'Mya Mya', 'Myint', 'emp14@tts.local', 'HASHED_P@ssw0rd123!', '09300000025', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (26, 'emp15', 'Lwin Lwin', 'Htun', 'emp15@tts.local', 'HASHED_P@ssw0rd123!', '09300000026', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (27, 'emp16', 'Phyu Phyu', 'Soe', 'emp16@tts.local', 'HASHED_P@ssw0rd123!', '09300000027', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (28, 'emp17', 'Myat Myat', 'Aung', 'emp17@tts.local', 'HASHED_P@ssw0rd123!', '09300000028', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (29, 'emp18', 'Nyein Nyein', 'Win', 'emp18@tts.local', 'HASHED_P@ssw0rd123!', '09300000029', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (30, 'emp19', 'Kyu Kyu', 'Hlaing', 'emp19@tts.local', 'HASHED_P@ssw0rd123!', '09300000030', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (31, 'emp20', 'San San', 'Aye', 'emp20@tts.local', 'HASHED_P@ssw0rd123!', '09300000031', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (32, 'emp21', 'Si Si', 'Ko', 'emp21@tts.local', 'HASHED_P@ssw0rd123!', '09300000032', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (33, 'emp22', 'Moe Moe', 'Thant', 'emp22@tts.local', 'HASHED_P@ssw0rd123!', '09300000033', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (34, 'emp23', 'Yu Yu', 'Naing', 'emp23@tts.local', 'HASHED_P@ssw0rd123!', '09300000034', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (35, 'emp24', 'Pan Pan', 'Wai', 'emp24@tts.local', 'HASHED_P@ssw0rd123!', '09300000035', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (36, 'emp25', 'Khaing', 'Khine', 'emp25@tts.local', 'HASHED_P@ssw0rd123!', '09300000036', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (37, 'emp26', 'Thiri', 'Aung', 'emp26@tts.local', 'HASHED_P@ssw0rd123!', '09300000037', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (38, 'emp27', 'Hnin', 'Aye', 'emp27@tts.local', 'HASHED_P@ssw0rd123!', '09300000038', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (39, 'emp28', 'Wint', 'Oo', 'emp28@tts.local', 'HASHED_P@ssw0rd123!', '09300000039', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (40, 'emp29', 'Chan', 'Moe', 'emp29@tts.local', 'HASHED_P@ssw0rd123!', '09300000040', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (41, 'emp30', 'Aye', 'Mon', 'emp30@tts.local', 'HASHED_P@ssw0rd123!', '09300000041', 3, TRUE, '2026-06-01T00:00:00.000', 1, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (42, 'mgr11', 'Htoo Aung', 'Lwin', 'mgr01@gmail.com', 'AQAAAAIAAYagAAAAECTH+CrFqAOxC9VajYmq7WJaaaYZ+jvjO8+XO7ovwRn7YejMYZTLTEROurI6YMbMKg==', '234565432', 2, TRUE, '2026-06-17T07:48:27.583', NULL, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (43, 'testuser', 'Test', 'User', 'test@example.com', 'AQAAAAIAAYagAAAAEA5lUHPsaeUPeUaznoHnjOvHSm2skqAERD9wbyJnHcHWNnuBdy6lnjRF3EtAy4FJIQ==', '', 3, TRUE, '2026-06-18T09:25:35.540', NULL, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (44, 'Ths_16', 'Thin Htet', 'San', 'aiyurina161020@gmail.com', 'AQAAAAIAAYagAAAAEHpOQiiSeZkhqxFD+4VtPnq82TW4apBdt1qvCzt2fPFxAUyzYb5mm1DBmvAp5QfRYw==', '09260022544', 3, TRUE, '2026-06-18T15:50:16.737', NULL, NULL, NULL, FALSE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (45, 'testing', 'test', 'ing', 'testing@gmail.com', 'AQAAAAIAAYagAAAAEHtWl016AGw9A8en4pQ20vZM/Lm1drEQTuZEbno45E2r9TkhFcTGNzECxOdFb2HzvQ==', '0923847568584', 4, TRUE, '2026-06-19T13:02:46.403', NULL, NULL, NULL, TRUE); +INSERT INTO "Users" ("Id", "Username", "FirstName", "LastName", "Email", "PasswordHash", "Phone", "RoleId", "IsActive", "CreatedAt", "CreatedBy", "UpdatedAt", "UpdatedBy", "IsDeleted") VALUES (46, 'maungaye', 'Maung', 'Aye', 'maungaye@gmail.com', 'AQAAAAIAAYagAAAAEGMdoiwJ634iuAxFxHUb+Qqkp1lQ6ein6A4i8EWlCvaofif8dswsERK9jHixckQddw==', '098765432234', 3, TRUE, '2026-06-22T04:13:19.313', NULL, NULL, NULL, FALSE); + +-- Reset primary key sequences for tables with auto-increment IDs +SELECT setval(pg_get_serial_sequence('"Roles"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Roles"; +SELECT setval(pg_get_serial_sequence('"Users"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Users"; +SELECT setval(pg_get_serial_sequence('"Menus"', 'MenuId'), COALESCE(MAX("MenuId"), 1)) FROM "Menus"; +SELECT setval(pg_get_serial_sequence('"Permissions"', 'PermissionId'), COALESCE(MAX("PermissionId"), 1)) FROM "Permissions"; +SELECT setval(pg_get_serial_sequence('"RoleMenus"', 'RoleMenuId'), COALESCE(MAX("RoleMenuId"), 1)) FROM "RoleMenus"; +SELECT setval(pg_get_serial_sequence('"RolePermissions"', 'RolePermissionId'), COALESCE(MAX("RolePermissionId"), 1)) FROM "RolePermissions"; +SELECT setval(pg_get_serial_sequence('"Projects"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Projects"; +SELECT setval(pg_get_serial_sequence('"Tasks"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Tasks"; +SELECT setval(pg_get_serial_sequence('"Comments"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Comments"; +SELECT setval(pg_get_serial_sequence('"AuditLogs"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "AuditLogs"; +SELECT setval(pg_get_serial_sequence('"Issues"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "Issues"; +SELECT setval(pg_get_serial_sequence('"notifications"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "notifications"; +SELECT setval(pg_get_serial_sequence('"user_devices"', 'Id'), COALESCE(MAX("Id"), 1)) FROM "user_devices"; +SET session_replication_role = 'origin'; +COMMIT; diff --git a/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj b/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj new file mode 100644 index 0000000000000000000000000000000000000000..63d34d0395761bbc1efd7aef94e487732a48aa47 --- /dev/null +++ b/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/TaskTrackingSystem.Shared/DisplayFormats.cs b/TaskTrackingSystem.Shared/DisplayFormats.cs new file mode 100644 index 0000000000000000000000000000000000000000..0d6ed8c47e97f1971b2a25d5f2066720aa48382f --- /dev/null +++ b/TaskTrackingSystem.Shared/DisplayFormats.cs @@ -0,0 +1,17 @@ +using System.Globalization; + +namespace TaskTrackingSystem.Shared; + +public static class DisplayFormats +{ + public const string DateFormat = "dd-MM-yyyy"; + + public static string Date(DateTime value) => + value.ToString(DateFormat, CultureInfo.InvariantCulture); + + public static string Date(DateTime? value) => + value.HasValue ? Date(value.Value) : string.Empty; + + public static string Date(DateOnly value) => + value.ToString(DateFormat, CultureInfo.InvariantCulture); +} diff --git a/TaskTrackingSystem.Shared/Enums/TaskPriority.cs b/TaskTrackingSystem.Shared/Enums/TaskPriority.cs new file mode 100644 index 0000000000000000000000000000000000000000..0a66a55a1fec0f5265647f0b5a91666ab75ec8cc --- /dev/null +++ b/TaskTrackingSystem.Shared/Enums/TaskPriority.cs @@ -0,0 +1,11 @@ +using System; + +namespace TaskTrackingSystem.Shared.Enums +{ + public enum TaskPriority : long + { + Low = 1, + Medium = 2, + High = 3 + } +} diff --git a/TaskTrackingSystem.Shared/Enums/TaskStatus.cs b/TaskTrackingSystem.Shared/Enums/TaskStatus.cs new file mode 100644 index 0000000000000000000000000000000000000000..6a031ea61f37a162f1d6f268ffdd2165ee552933 --- /dev/null +++ b/TaskTrackingSystem.Shared/Enums/TaskStatus.cs @@ -0,0 +1,11 @@ +using System; + +namespace TaskTrackingSystem.Shared.Enums +{ + public enum AppTaskStatus : long + { + Todo = 1, + InProgress = 2, + Done = 3 + } +} diff --git a/TaskTrackingSystem.Shared/Localization/AppLocalization.cs b/TaskTrackingSystem.Shared/Localization/AppLocalization.cs new file mode 100644 index 0000000000000000000000000000000000000000..f1b94af97eca575951e157a73c41ad973dae1144 --- /dev/null +++ b/TaskTrackingSystem.Shared/Localization/AppLocalization.cs @@ -0,0 +1,615 @@ +using System.Globalization; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.Shared.Localization; + +public static class AppLocalization +{ + private static readonly IReadOnlyDictionary English = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["common.workspace"] = "Workspace", + ["common.signIn"] = "Sign In", + ["common.signOut"] = "Sign Out", + ["common.signOutTitle"] = "Sign Out?", + ["common.signOutDescription"] = "Are you sure you want to sign out of Taskify?", + ["common.cancel"] = "Cancel", + ["common.confirm"] = "Confirm", + ["common.confirmAction"] = "Confirm", + ["common.areYouSure"] = "Are you sure?", + ["common.cannotUndo"] = "This action cannot be undone.", + ["common.yes"] = "Yes", + ["common.no"] = "No", + ["common.save"] = "Save", + ["common.create"] = "Create", + ["common.update"] = "Update", + ["common.delete"] = "Delete", + ["common.confirmSignOut"] = "Yes, Sign Out", + ["common.listView"] = "List View", + ["common.chartView"] = "Chart View", + ["common.search"] = "Search", + ["common.reset"] = "Reset", + ["common.clear"] = "Clear", + ["common.all"] = "All", + ["common.loading"] = "Loading...", + ["common.close"] = "Close", + ["common.openNavigation"] = "Open navigation", + ["common.closeNavigation"] = "Close navigation", + ["common.index"] = "No.", + ["common.exportExcel"] = "Export Excel", + ["common.exportPdf"] = "Export PDF", + ["common.actions"] = "Actions", + ["common.status"] = "Status", + ["common.priority"] = "Priority", + ["common.assignee"] = "Assignee", + ["common.project"] = "Project", + ["common.task"] = "Task", + ["common.taskProject"] = "Task / Project", + ["common.issue"] = "Issue", + ["common.user"] = "User", + ["common.role"] = "Role", + ["common.module"] = "Module", + ["common.description"] = "Description", + ["common.date"] = "Date", + ["common.startDate"] = "Start Date", + ["common.endDate"] = "End Date", + ["common.dueDate"] = "Due Date", + ["common.due"] = "Due", + ["common.hours"] = "Hours", + ["common.risk"] = "Risk", + ["common.blocked"] = "Blocked", + ["common.overdue"] = "Overdue", + ["common.done"] = "Done", + ["common.inProgress"] = "In Progress", + ["common.todo"] = "To Do", + ["common.resolved"] = "Resolved", + ["common.clearStatus"] = "Clear", + ["common.none"] = "None", + ["common.unassigned"] = "Unassigned", + ["common.noDescription"] = "No description", + ["common.noneRecorded"] = "None recorded", + ["common.na"] = "N/A", + ["common.noAccessItems"] = "No access items are available for this role.", + ["common.accessDenied"] = "Access Denied", + ["common.accessDeniedDescription"] = "You do not have permission to access this page. Please contact your system administrator.", + ["common.backToDashboard"] = "Back to Dashboard", + ["common.noDataYet"] = "No data yet", + ["common.createFirstEntry"] = "Create your first entry to get started.", + ["common.loadingAuditLogs"] = "Loading audit logs...", + ["common.loadingUsers"] = "Loading users...", + ["common.loadingProjects"] = "Loading projects...", + ["common.loadingTasks"] = "Loading tasks...", + ["common.searchPlaceholder"] = "Search by name, email, username...", + ["common.searchTasks"] = "Search Task", + ["common.searchByUser"] = "Search by user, action, module, or details...", + ["common.searchByTitle"] = "Search by title or project...", + ["common.searchByTitleOrProject"] = "Search by title or project...", + ["common.searchProjects"] = "Search projects...", + ["common.searchTeamMembers"] = "Search team members...", + ["common.searchProjectMembers"] = "Search project members...", + ["common.searchIssues"] = "Search issue title, description or assignee...", + ["common.searchByIssueAssignee"] = "Search Issues / Assignee", + ["common.loadingSpinner"] = "Loading...", + ["common.loadingIssues"] = "Loading issues...", + ["common.loadingTasksAndUsers"] = "Loading tasks and users...", + ["common.loadingProjectsAndUsers"] = "Loading projects and users...", + ["common.loadingProjectMembers"] = "Loading project members...", + ["common.loadingMembers"] = "Loading members...", + ["common.noResults"] = "No results found.", + ["common.notApplicable"] = "Not applicable", + ["common.selectAll"] = "Select All", + ["common.deselectAll"] = "Deselect All", + ["common.selected"] = "selected", + ["common.viewDetails"] = "View details", + ["common.currentLanguage"] = "Switch language", + ["common.languageEnglish"] = "English", + ["common.languageBurmese"] = "Burmese", + ["common.allTasks"] = "All tasks", + ["common.allProjects"] = "All projects", + ["common.allRoles"] = "All Roles", + ["common.noProjectsFound"] = "No projects found.", + ["common.noTasksFound"] = "No tasks found.", + ["common.noTeamMembersFound"] = "No team members match your search criteria.", + ["common.noMembersMatchSearch"] = "No members match the current search.", + ["common.noTaskSelected"] = "No Task Selected", + ["common.noProjectSelected"] = "No Project Selected", + ["common.noProject"] = "No Project", + ["common.currentlyAssigningTask"] = "Currently Assigning Task", + ["common.currentlyManaging"] = "Currently Managing", + ["common.selectProject"] = "Select Project", + ["common.selectTask"] = "Select task...", + ["common.issueTitle"] = "Issue Title", + ["common.issueDescription"] = "Description", + ["common.blockedStatus"] = "Blocked Status", + ["common.escalationLevel"] = "Escalation Level", + ["common.blockedBy"] = "Blocked By", + ["common.delayReason"] = "Delay Reason", + ["common.actualHours"] = "Actual Hours", + ["common.estimatedHours"] = "Estimated Hours", + ["common.assignedToMeOnly"] = "Assigned to me only", + ["common.saveAssignment"] = "Save Assignment", + ["common.saveAssignmentChanges"] = "Save Assignment Changes", + ["common.saveChanges"] = "Save Changes", + ["common.addIssue"] = "Add Issue", + ["common.addIssueTitle"] = "Add Issue?", + ["common.editIssue"] = "Edit Issue", + ["common.noTaskSelectedForIssue"] = "No task selected", + ["common.unassignedMembers"] = "Unassigned", + ["common.noMembersMatchSearch"] = "No members match the current search.", + ["common.removeOwnerFromTask"] = "Remove owner from this task", + + ["page.dashboard"] = "Dashboard", + ["page.managerDashboard"] = "Manager Dashboard", + ["page.employeeDashboard"] = "Employee Dashboard", + ["page.projects"] = "Projects", + ["page.projectAssignment"] = "Project Assignment", + ["page.tasks"] = "Tasks", + ["page.kanbanBoard"] = "Kanban Board", + ["page.taskAssignment"] = "Task Assignment", + ["page.taskDetails"] = "Task Details", + ["page.issues"] = "Issues", + ["page.addIssue"] = "Add Issue", + ["page.taskReport"] = "Task Report", + ["page.timeTracking"] = "Time Tracking", + ["page.projectProgress"] = "Project Progress", + ["page.overdueTasks"] = "Overdue Tasks", + ["page.employeeReport"] = "Employee Report", + ["page.rolesAccess"] = "Roles & Access", + ["page.users"] = "Users", + ["page.auditLogs"] = "Audit Logs", + ["page.issueReport"] = "Issue Report", + ["page.projectList"] = "Project List", + ["page.taskList"] = "Task List", + ["page.taskBacklog"] = "Task Backlog", + ["page.roleLayouts"] = "Role Layouts", + + ["page.dashboard.desc"] = "A quick overview of work, risk, and progress.", + ["page.managerDashboard.desc"] = "Track projects, people, and delivery health.", + ["page.employeeDashboard.desc"] = "Focus on your tasks, due dates, and progress.", + ["page.projects.desc"] = "Manage project plans, timelines, and assignments.", + ["page.projectAssignment.desc"] = "Assign team members to the right projects.", + ["page.tasks.desc"] = "Review, filter, and update work items.", + ["page.kanbanBoard.desc"] = "Move work through status columns.", + ["page.taskAssignment.desc"] = "Assign tasks to project members.", + ["page.taskDetails.desc"] = "Review task progress, comments, and updates.", + ["page.issues.desc"] = "Track blockers, notes, and daily work progress.", + ["page.addIssue.desc"] = "Record a new issue against a task.", + ["page.taskReport.desc"] = "Analyze task activity and export task reports.", + ["page.timeTracking.desc"] = "Review effort by task, project, and assignee.", + ["page.projectProgress.desc"] = "Monitor delivery health across active projects.", + ["page.overdueTasks.desc"] = "Focus on delayed work that needs attention.", + ["page.employeeReport.desc"] = "See how work is distributed across people.", + ["page.rolesAccess.desc"] = "Control user roles, menus, and permissions.", + ["page.users.desc"] = "Manage people, status, and account details.", + ["page.auditLogs.desc"] = "Review system activity and changes.", + ["page.issueReport.desc"] = "Track daily execution, backlog health, and issue-level effort.", + ["page.projectList.desc"] = "Review project details and progress at a glance.", + ["page.taskList.desc"] = "Review, filter, and update task records.", + ["page.taskBacklog.desc"] = "See unassigned and queued work items.", + ["page.roleLayouts.desc"] = "Review access group layouts and permissions.", + + ["menu.DASHBOARD"] = "Dashboard", + ["menu.DASHBOARD_ADMIN"] = "Dashboard", + ["menu.DASHBOARD_MANAGER"] = "Dashboard", + ["menu.DASHBOARD_EMPLOYEE"] = "Dashboard", + ["menu.PROJECTS"] = "Projects", + ["menu.PROJECTS_LIST"] = "Project List", + ["menu.PROJECTS_ASSIGN"] = "Project Assign", + ["menu.TASKS"] = "Tasks", + ["menu.TASKS_LIST"] = "Task List", + ["menu.TASKS_BOARD"] = "Kanban Board", + ["menu.TASKS_ASSIGN"] = "Task Assign", + ["menu.TASKS_BACKLOG"] = "Task Backlog", + ["menu.ISSUES_ADD"] = "Add Issue", + ["menu.ISSUES_LIST"] = "Issue List", + ["menu.REPORTS"] = "Reports", + ["menu.REPORTS_TASKS"] = "Task Report", + ["menu.REPORTS_TIMESHEET"] = "Time Tracking", + ["menu.REPORTS_OVERDUE"] = "Overdue Tasks", + ["menu.REPORTS_EMPLOYEES"] = "Employee Report", + ["menu.REPORTS_PROJECTS"] = "Project Progress", + ["menu.REPORTS_ISSUES"] = "Issue Report", + ["menu.AUDIT_LOGS"] = "Audit Logs", + ["menu.USERS"] = "Users", + ["menu.ROLES"] = "Roles", + ["menu.DASHBOARD_WIDGETS"] = "Role Layouts", + + ["status.todo"] = "To Do", + ["status.inprogress"] = "In Progress", + ["status.done"] = "Done", + ["status.overdue"] = "Overdue", + ["status.blocked"] = "Blocked", + ["status.clear"] = "Clear", + ["status.resolved"] = "Resolved", + ["status.uncompleted"] = "Uncompleted", + ["priority.low"] = "Low", + ["priority.medium"] = "Medium", + ["priority.high"] = "High", + + ["action.search"] = "Search", + ["action.reset"] = "Reset", + ["action.exportExcel"] = "Export Excel", + ["action.exportPdf"] = "Export PDF", + ["action.edit"] = "Edit", + ["action.delete"] = "Delete", + ["action.add"] = "Add", + ["action.create"] = "Create", + ["action.update"] = "Update", + ["action.save"] = "Save", + ["action.assign"] = "Assign", + ["action.viewTasks"] = "View Tasks", + ["action.archiveDone"] = "Archive Done", + ["action.clear"] = "Clear", + + ["result.loading"] = "Loading...", + ["result.saved"] = "Saved successfully.", + ["result.created"] = "Created successfully.", + ["result.updated"] = "Updated successfully.", + ["result.deleted"] = "Deleted successfully.", + + ["report.periodFilter"] = "Period", + ["report.daily"] = "Daily (Today)", + ["report.monthly"] = "Monthly (This Month)", + ["report.yearly"] = "Yearly (This Year)", + ["report.customRange"] = "Custom Range", + ["report.selectedProject"] = "Selected project", + ["report.openIssues"] = "Open issues", + ["report.lastActivity"] = "Last activity", + ["report.noTasksFound"] = "No Tasks Found", + ["report.noTasksFoundDesc"] = "There are no tasks matching your filters. Try adjusting your search query or filters.", + ["report.noIssuesFound"] = "No issues found", + ["report.noIssuesFoundDesc"] = "No issues matched the current report filters.", + ["report.healthCritical"] = "Critical", + ["report.healthAtRisk"] = "At Risk", + ["report.healthGood"] = "Healthy", + ["report.delay"] = "Delay", + ["report.blockedBy"] = "Blocked by", + ["report.noEffortEntries"] = "No effort entries", + ["report.noEffortEntriesDesc"] = "No timesheet effort entries matched your filters.", + ["report.estimatedVsActual"] = "Estimated vs actual effort", + ["report.utilizationGauge"] = "Utilization gauge", + ["report.estimateAccuracy"] = "Estimate accuracy", + ["report.effortByProject"] = "Effort by project", + ["report.effortLeaderboard"] = "Effort leaderboard", + ["report.workloadHeatmap"] = "Workload heatmap", + ["report.capacitySupportList"] = "Capacity support list", + ["report.completedVsOpenIssues"] = "Completed vs open issues", + ["report.overdueSupport"] = "Overdue support", + ["report.completionPattern"] = "Completion pattern", + ["report.projectHealthMatrix"] = "Project health matrix", + ["report.completionVsRisk"] = "Completion vs risk", + ["report.timelineSummary"] = "Timeline summary", + ["report.openOverdueBlockingIssues"] = "Open, overdue, and blocking issues by project", + ["report.topAtRiskProjects"] = "Top at-risk projects", + ["report.keyInsights"] = "Key Insights", + + ["validation.startDateRequired"] = "Start date is required.", + ["validation.dueDateRequired"] = "Due date is required.", + ["validation.dueDateBeforeStart"] = "Due date cannot be earlier than start date.", + ["validation.projectNameRequired"] = "Project name is required.", + ["validation.taskTitleRequired"] = "Task title is required.", + ["validation.selectProjectRequired"] = "Please select a project.", + ["validation.roleNameRequired"] = "Role name is required.", + ["validation.passwordMinLength"] = "Password must be at least 6 characters.", + ["validation.usernameMinLength"] = "Username must be at least 3 characters long.", + ["validation.usernameNoSpaces"] = "Username cannot contain spaces.", + ["validation.usernameInvalidCharacters"] = "Username cannot contain spaces, and can only contain letters, numbers, underscores (_), and periods (.)", + ["validation.passwordMinLengthRule"] = "Password must be at least 8 characters long.", + ["validation.passwordComplexityRule"] = "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character." + }; + + private static readonly IReadOnlyDictionary Burmese = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["common.workspace"] = "အလုပ်ခွင်", + ["common.signIn"] = "အကောင့်ဝင်ရန်", + ["common.signOut"] = "အကောင့်ထွက်ရန်", + ["common.signOutTitle"] = "အကောင့်ထွက်မလား?", + ["common.signOutDescription"] = "Taskify မှ အကောင့်ထွက်ရန် သေချာပါသလား?", + ["common.cancel"] = "မလုပ်တော့ပါ", + ["common.confirm"] = "အတည်ပြုမည်", + ["common.confirmAction"] = "အတည်ပြုမည်", + ["common.areYouSure"] = "သင် သေချာပါသလား?", + ["common.cannotUndo"] = "ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်မရပါ။", + ["common.yes"] = "ဟုတ်ကဲ့", + ["common.no"] = "မဟုတ်ပါ", + ["common.save"] = "သိမ်းမည်", + ["common.create"] = "ဖန်တီးမည်", + ["common.update"] = "အပ်ဒိတ်လုပ်မည်", + ["common.delete"] = "ဖျက်မည်", + ["common.confirmSignOut"] = "ဟုတ်ကဲ့၊ အကောင့်ထွက်မည်", + ["common.listView"] = "စာရင်းမြင်ကွင်း", + ["common.chartView"] = "ဇယားမြင်ကွင်း", + ["common.search"] = "ရှာမည်", + ["common.reset"] = "ပြန်လည်သတ်မှတ်မည်", + ["common.clear"] = "ရှင်းမည်", + ["common.all"] = "အားလုံး", + ["common.loading"] = "ဖွင့်နေသည်...", + ["common.close"] = "ပိတ်မည်", + ["common.openNavigation"] = "မီနူးဖွင့်ရန်", + ["common.closeNavigation"] = "မီနူးပိတ်ရန်", + ["common.index"] = "နံပါတ်", + ["common.exportExcel"] = "Excel ထုတ်မည်", + ["common.exportPdf"] = "PDF ထုတ်မည်", + ["common.actions"] = "လုပ်ဆောင်ချက်များ", + ["common.status"] = "အခြေအနေ", + ["common.priority"] = "ဦးစားပေး", + ["common.assignee"] = "တာဝန်ယူသူ", + ["common.project"] = "စီမံကိန်း", + ["common.task"] = "လုပ်ငန်း", + ["common.taskProject"] = "လုပ်ငန်း / စီမံကိန်း", + ["common.issue"] = "ပြဿနာ", + ["common.user"] = "အသုံးပြုသူ", + ["common.role"] = "အခန်းကဏ္ဍ", + ["common.module"] = "မော်ဂျူး", + ["common.description"] = "ဖော်ပြချက်", + ["common.date"] = "ရက်စွဲ", + ["common.startDate"] = "စတင်ရက်", + ["common.endDate"] = "ပြီးဆုံးရက်", + ["common.dueDate"] = "ပြီးဆုံးရန်ရက်", + ["common.due"] = "ပြီးဆုံးရက်", + ["common.hours"] = "နာရီ", + ["common.risk"] = "အန္တရာယ်", + ["common.blocked"] = "ပိတ်ဆို့ထားသည်", + ["common.overdue"] = "နောက်ကျနေသည်", + ["common.done"] = "ပြီးဆုံး", + ["common.inProgress"] = "ဆောင်ရွက်နေသည်", + ["common.todo"] = "လုပ်ရန်", + ["common.resolved"] = "ဖြေရှင်းပြီး", + ["common.clearStatus"] = "ရှင်းလင်း", + ["common.none"] = "မရှိ", + ["common.unassigned"] = "မချမှတ်ရသေး", + ["common.noDescription"] = "ဖော်ပြချက်မရှိ", + ["common.noneRecorded"] = "မှတ်တမ်းမရှိ", + ["common.na"] = "မသက်ဆိုင်ပါ", + ["common.noAccessItems"] = "ဤအခန်းကဏ္ဍအတွက် ဝင်ရောက်ခွင့်မရှိသေးပါ။", + ["common.accessDenied"] = "ဝင်ရောက်ခွင့် မရှိပါ", + ["common.accessDeniedDescription"] = "ဤစာမျက်နှာသို့ ဝင်ရောက်ရန် သင်၌ ခွင့်ပြုချက်မရှိပါ။ စနစ်စီမံသူကို ဆက်သွယ်ပါ။", + ["common.backToDashboard"] = "ဒက်ရှ်ဘုတ်သို့ ပြန်သွားမည်", + ["common.noDataYet"] = "အချက်အလက် မရှိသေးပါ", + ["common.createFirstEntry"] = "စတင်ရန် ပထမဆုံး အချက်အလက်ကို ဖန်တီးပါ။", + ["common.loadingAuditLogs"] = "မှတ်တမ်းများ ဖွင့်နေသည်...", + ["common.loadingUsers"] = "အသုံးပြုသူများ ဖွင့်နေသည်...", + ["common.loadingProjects"] = "စီမံကိန်းများ ဖွင့်နေသည်...", + ["common.loadingTasks"] = "လုပ်ငန်းများ ဖွင့်နေသည်...", + ["common.searchPlaceholder"] = "အမည်၊ အီးမေးလ်၊ အသုံးပြုသူအမည်ဖြင့် ရှာပါ...", + ["common.searchTasks"] = "အလုပ်ရှာရန်", + ["common.searchByTitleOrProject"] = "ခေါင်းစဉ် သို့မဟုတ် စီမံကိန်းဖြင့် ရှာပါ...", + ["common.searchByUser"] = "အသုံးပြုသူ၊ လုပ်ဆောင်ချက်၊ မော်ဂျူး သို့မဟုတ် အသေးစိတ်ဖြင့် ရှာပါ...", + ["common.searchByTitle"] = "ခေါင်းစဉ် သို့မဟုတ် စီမံကိန်းအမည်ဖြင့် ရှာပါ...", + ["common.searchProjects"] = "စီမံကိန်းများ ရှာပါ...", + ["common.searchTeamMembers"] = "အဖွဲ့ဝင်များ ရှာပါ...", + ["common.searchProjectMembers"] = "စီမံကိန်းအဖွဲ့ဝင်များ ရှာပါ...", + ["common.searchIssues"] = "ပြဿနာ ခေါင်းစဉ်၊ ဖော်ပြချက် သို့မဟုတ် တာဝန်ယူသူဖြင့် ရှာပါ...", + ["common.searchByIssueAssignee"] = "ပြဿနာ / တာဝန်ယူသူ ရှာရန်", + ["common.loadingSpinner"] = "ဖွင့်နေသည်...", + ["common.loadingIssues"] = "ပြဿနာများ ဖွင့်နေသည်...", + ["common.loadingTasksAndUsers"] = "လုပ်ငန်းများနှင့် အသုံးပြုသူများ ဖွင့်နေသည်...", + ["common.loadingProjectsAndUsers"] = "စီမံကိန်းများနှင့် အသုံးပြုသူများ ဖွင့်နေသည်...", + ["common.loadingProjectMembers"] = "စီမံကိန်းအဖွဲ့ဝင်များ ဖွင့်နေသည်...", + ["common.loadingMembers"] = "အဖွဲ့ဝင်များ ဖွင့်နေသည်...", + ["common.noResults"] = "ရလဒ်မတွေ့ပါ။", + ["common.notApplicable"] = "မသက်ဆိုင်ပါ", + ["common.selectAll"] = "အားလုံးရွေးမည်", + ["common.deselectAll"] = "အားလုံးဖြုတ်မည်", + ["common.selected"] = "ရွေးထားသည်", + ["common.viewDetails"] = "အသေးစိတ်ကြည့်မည်", + ["common.currentLanguage"] = "ဘာသာစကားပြောင်းရန်", + ["common.languageEnglish"] = "အင်္ဂလိပ်", + ["common.languageBurmese"] = "မြန်မာ", + ["common.allTasks"] = "အလုပ်အားလုံး", + ["common.allProjects"] = "စီမံကိန်းအားလုံး", + ["common.allRoles"] = "အခန်းကဏ္ဍအားလုံး", + ["common.noProjectsFound"] = "စီမံကိန်းမတွေ့ပါ။", + ["common.noTasksFound"] = "လုပ်ငန်းမတွေ့ပါ။", + ["common.noTeamMembersFound"] = "သင်ရှာဖွေသည့် အဖွဲ့ဝင် မရှိပါ။", + ["common.noMembersMatchSearch"] = "လက်ရှိရှာဖွေမှုနှင့် ကိုက်ညီသည့် အဖွဲ့ဝင် မရှိပါ။", + ["common.noTaskSelected"] = "လုပ်ငန်း မရွေးထားပါ", + ["common.noProjectSelected"] = "စီမံကိန်း မရွေးထားပါ", + ["common.noProject"] = "စီမံကိန်း မရှိပါ", + ["common.currentlyAssigningTask"] = "လက်ရှိ ချထားနေသော လုပ်ငန်း", + ["common.currentlyManaging"] = "လက်ရှိ စီမံနေသည်", + ["common.selectProject"] = "စီမံကိန်း ရွေးရန်", + ["common.selectTask"] = "လုပ်ငန်း ရွေးရန်...", + ["common.issueTitle"] = "ပြဿနာ ခေါင်းစဉ်", + ["common.issueDescription"] = "ဖော်ပြချက်", + ["common.blockedStatus"] = "ပိတ်ဆို့မှု အခြေအနေ", + ["common.escalationLevel"] = "အဆင့်တိုးမှု အဆင့်", + ["common.blockedBy"] = "ပိတ်ဆို့နေသူ", + ["common.delayReason"] = "နှောင့်နှေးရသည့် အကြောင်းရင်း", + ["common.actualHours"] = "တကယ့် နာရီ", + ["common.estimatedHours"] = "ခန့်မှန်း နာရီ", + ["common.assignedToMeOnly"] = "ကျွန်ုပ်ထံပဲ ချထားထားသည်", + ["common.saveAssignment"] = "တာဝန်ချမှု သိမ်းမည်", + ["common.saveAssignmentChanges"] = "တာဝန်ချမှု ပြောင်းလဲချက်များ သိမ်းမည်", + ["common.saveChanges"] = "ပြောင်းလဲချက်များ သိမ်းမည်", + ["common.addIssue"] = "ပြဿနာ ထည့်မည်", + ["common.addIssueTitle"] = "ပြဿနာ ထည့်မည်လား?", + ["common.editIssue"] = "ပြဿနာ ပြင်မည်", + ["common.removeOwnerFromTask"] = "ဤလုပ်ငန်းမှ ပိုင်ရှင်ကို ဖယ်ရှားမည်", + + ["page.dashboard"] = "ဒက်ရှ်ဘုတ်", + ["page.managerDashboard"] = "မန်နေဂျာ ဒက်ရှ်ဘုတ်", + ["page.employeeDashboard"] = "ဝန်ထမ်း ဒက်ရှ်ဘုတ်", + ["page.projects"] = "စီမံကိန်းများ", + ["page.projectAssignment"] = "စီမံကိန်း ခွဲဝေခြင်း", + ["page.tasks"] = "လုပ်ငန်းများ", + ["page.kanbanBoard"] = "ကန်ဘန်းဘုတ်", + ["page.taskAssignment"] = "လုပ်ငန်း ခွဲဝေခြင်း", + ["page.taskDetails"] = "လုပ်ငန်း အသေးစိတ်", + ["page.issues"] = "ပြဿနာများ", + ["page.addIssue"] = "ပြဿနာ ထည့်ရန်", + ["page.taskReport"] = "လုပ်ငန်း အစီရင်ခံစာ", + ["page.timeTracking"] = "အချိန်မှတ်တမ်း", + ["page.projectProgress"] = "စီမံကိန်း တိုးတက်မှု", + ["page.overdueTasks"] = "နောက်ကျ လုပ်ငန်းများ", + ["page.employeeReport"] = "ဝန်ထမ်း အစီရင်ခံစာ", + ["page.rolesAccess"] = "အခန်းကဏ္ဍနှင့် ဝင်ခွင့်", + ["page.users"] = "အသုံးပြုသူများ", + ["page.auditLogs"] = "စနစ်မှတ်တမ်းများ", + ["page.issueReport"] = "ပြဿနာ အစီရင်ခံစာ", + ["page.projectList"] = "စီမံကိန်းစာရင်း", + ["page.taskList"] = "လုပ်ငန်းစာရင်း", + ["page.taskBacklog"] = "လုပ်ငန်း နောက်ခံစာရင်း", + ["page.roleLayouts"] = "အခန်းကဏ္ဍ အပြင်အဆင်များ", + + ["page.dashboard.desc"] = "အလုပ်များ၊ အန္တရာယ်များနှင့် တိုးတက်မှုများကို တစ်ချက်ကြည့်နိုင်သည်။", + ["page.managerDashboard.desc"] = "စီမံကိန်းများ၊ လူအင်အားနှင့် ပေးပို့မှုအခြေအနေကို စောင့်ကြည့်ပါ။", + ["page.employeeDashboard.desc"] = "သင့်လုပ်ငန်းများ၊ သတ်မှတ်ရက်နှင့် တိုးတက်မှုများကို အာရုံစိုက်ပါ။", + ["page.projects.desc"] = "စီမံကိန်းအစီအစဉ်၊ အချိန်ဇယားနှင့် ခွဲဝေမှုများကို စီမံပါ။", + ["page.projectAssignment.desc"] = "အဖွဲ့ဝင်များကို သင့်တော်သော စီမံကိန်းများသို့ ခွဲဝေပါ။", + ["page.tasks.desc"] = "လုပ်ငန်းများကို ကြည့်ရှု၊ စစ်ထုတ်၊ အပ်ဒိတ်လုပ်ပါ။", + ["page.kanbanBoard.desc"] = "အလုပ်များကို အခြေအနေကော်လံများအတိုင်း ရွှေ့ပါ။", + ["page.taskAssignment.desc"] = "လုပ်ငန်းများကို စီမံကိန်းအဖွဲ့ဝင်များသို့ ခွဲဝေပါ။", + ["page.taskDetails.desc"] = "လုပ်ငန်းတိုးတက်မှု၊ မှတ်ချက်များနှင့် အပ်ဒိတ်များကို ကြည့်ရှုပါ။", + ["page.issues.desc"] = "ပိတ်ဆို့မှုများ၊ မှတ်စုများနှင့် နေ့စဉ် အလုပ်တိုးတက်မှုကို ခြေရာခံပါ။", + ["page.addIssue.desc"] = "လုပ်ငန်းတစ်ခုအတွက် ပြဿနာအသစ်ကို မှတ်တမ်းတင်ပါ။", + ["page.taskReport.desc"] = "လုပ်ငန်းလှုပ်ရှားမှုကို ခွဲခြမ်းပြီး အစီရင်ခံစာများ ထုတ်ပါ။", + ["page.timeTracking.desc"] = "လုပ်ငန်း၊ စီမံကိန်းနှင့် တာဝန်ယူသူအလိုက် အားထုတ်မှုကို ကြည့်ရှုပါ။", + ["page.projectProgress.desc"] = "လက်ရှိ စီမံကိန်းများ၏ ပေးပို့မှုအခြေအနေကို စောင့်ကြည့်ပါ။", + ["page.overdueTasks.desc"] = "အရေးပေါ် အာရုံစိုက်ရန် လိုသည့် နောက်ကျလုပ်ငန်းများကို ကြည့်ပါ။", + ["page.employeeReport.desc"] = "အလုပ်တာဝန်များ လူတစ်ဦးချင်းစီအလိုက် မည်သို့ ဖြန့်ဝေထားသည်ကို ကြည့်ပါ။", + ["page.rolesAccess.desc"] = "အသုံးပြုသူအခန်းကဏ္ဍ၊ မီနူးများနှင့် ခွင့်ပြုချက်များကို ထိန်းချုပ်ပါ။", + ["page.users.desc"] = "လူများ၊ အခြေအနေများနှင့် အကောင့်အသေးစိတ်များကို စီမံပါ။", + ["page.auditLogs.desc"] = "စနစ်လှုပ်ရှားမှုများနှင့် ပြောင်းလဲမှုများကို ပြန်လည်ကြည့်ရှုပါ။", + ["page.issueReport.desc"] = "နေ့စဉ် ဆောင်ရွက်မှု၊ နောက်ကျမှုနှင့် ပြဿနာအလိုက် အားထုတ်မှုကို ခြေရာခံပါ။", + ["page.projectList.desc"] = "စီမံကိန်းအသေးစိတ်နှင့် တိုးတက်မှုကို တစ်ချက်ကြည့်ပါ။", + ["page.taskList.desc"] = "လုပ်ငန်းမှတ်တမ်းများကို ကြည့်ရှု၊ စစ်ထုတ်၊ အပ်ဒိတ်လုပ်ပါ။", + ["page.taskBacklog.desc"] = "မခွဲဝေသေးသော နှင့် စောင့်ဆိုင်းနေသော အလုပ်များကို ကြည့်ပါ။", + ["page.roleLayouts.desc"] = "ဝင်ခွင့်အုပ်စု ပုံစံများနှင့် ခွင့်ပြုချက်များကို ကြည့်ပါ။", + + ["menu.DASHBOARD"] = "ဒက်ရှ်ဘုတ်", + ["menu.DASHBOARD_ADMIN"] = "ဒက်ရှ်ဘုတ်", + ["menu.DASHBOARD_MANAGER"] = "ဒက်ရှ်ဘုတ်", + ["menu.DASHBOARD_EMPLOYEE"] = "ဒက်ရှ်ဘုတ်", + ["menu.PROJECTS"] = "စီမံကိန်းများ", + ["menu.PROJECTS_LIST"] = "စီမံကိန်းစာရင်း", + ["menu.PROJECTS_ASSIGN"] = "စီမံကိန်း ခွဲဝေခြင်း", + ["menu.TASKS"] = "လုပ်ငန်းများ", + ["menu.TASKS_LIST"] = "လုပ်ငန်းစာရင်း", + ["menu.TASKS_BOARD"] = "ကန်ဘန်းဘုတ်", + ["menu.TASKS_ASSIGN"] = "လုပ်ငန်း ခွဲဝေခြင်း", + ["menu.TASKS_BACKLOG"] = "လုပ်ငန်း နောက်ခံစာရင်း", + ["menu.ISSUES_ADD"] = "ပြဿနာ ထည့်ရန်", + ["menu.ISSUES_LIST"] = "ပြဿနာ စာရင်း", + ["menu.REPORTS"] = "အစီရင်ခံစာများ", + ["menu.REPORTS_TASKS"] = "လုပ်ငန်း အစီရင်ခံစာ", + ["menu.REPORTS_TIMESHEET"] = "အချိန်မှတ်တမ်း", + ["menu.REPORTS_OVERDUE"] = "နောက်ကျ လုပ်ငန်းများ", + ["menu.REPORTS_EMPLOYEES"] = "ဝန်ထမ်း အစီရင်ခံစာ", + ["menu.REPORTS_PROJECTS"] = "စီမံကိန်း တိုးတက်မှု", + ["menu.REPORTS_ISSUES"] = "ပြဿနာ အစီရင်ခံစာ", + ["menu.AUDIT_LOGS"] = "စနစ်မှတ်တမ်းများ", + ["menu.USERS"] = "အသုံးပြုသူများ", + ["menu.ROLES"] = "အခန်းကဏ္ဍများ", + ["menu.DASHBOARD_WIDGETS"] = "အခန်းကဏ္ဍ အပြင်အဆင်များ", + + ["status.todo"] = "လုပ်ရန်", + ["status.inprogress"] = "ဆောင်ရွက်နေသည်", + ["status.done"] = "ပြီးဆုံး", + ["status.overdue"] = "နောက်ကျ", + ["status.blocked"] = "ပိတ်ဆို့", + ["status.clear"] = "ရှင်း", + ["status.resolved"] = "ဖြေရှင်းပြီး", + ["status.uncompleted"] = "မပြီးသေး", + ["priority.low"] = "နိမ့်", + ["priority.medium"] = "အလယ်", + ["priority.high"] = "မြင့်", + + ["action.search"] = "ရှာမည်", + ["action.reset"] = "ပြန်လည်သတ်မှတ်မည်", + ["action.exportExcel"] = "Excel ထုတ်မည်", + ["action.exportPdf"] = "PDF ထုတ်မည်", + ["action.edit"] = "ပြင်မည်", + ["action.delete"] = "ဖျက်မည်", + ["action.add"] = "ထည့်မည်", + ["action.create"] = "ဖန်တီးမည်", + ["action.update"] = "အပ်ဒိတ်လုပ်မည်", + ["action.save"] = "သိမ်းမည်", + ["action.assign"] = "ခွဲဝေမည်", + ["action.viewTasks"] = "လုပ်ငန်းများ ကြည့်မည်", + ["action.archiveDone"] = "ပြီးဆုံးများ သိမ်းဆည်းမည်", + ["action.clear"] = "ရှင်းမည်", + + ["result.loading"] = "ဖွင့်နေသည်...", + ["result.saved"] = "အောင်မြင်စွာ သိမ်းပြီးပါပြီ။", + ["result.created"] = "အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + ["result.updated"] = "အောင်မြင်စွာ အပ်ဒိတ်ပြီးပါပြီ။", + ["result.deleted"] = "အောင်မြင်စွာ ဖျက်ပြီးပါပြီ။", + ["report.periodFilter"] = "ကာလ", + ["report.daily"] = "နေ့စဉ် (ယနေ့)", + ["report.monthly"] = "လစဉ် (ယခုလ)", + ["report.yearly"] = "နှစ်စဉ် (ယခုနှစ်)", + ["report.customRange"] = "စိတ်ကြိုက်အပိုင်းအခြား", + ["report.selectedProject"] = "ရွေးထားသော စီမံကိန်း", + ["report.openIssues"] = "ဖွင့်ထားသော ပြဿနာများ", + ["report.lastActivity"] = "နောက်ဆုံး လုပ်ဆောင်ချက်", + ["report.noTasksFound"] = "အလုပ်မတွေ့ပါ", + ["report.noTasksFoundDesc"] = "လက်ရှိစစ်ထုတ်မှုများနှင့် ကိုက်ညီသော အလုပ် မရှိပါ။ ရှာဖွေမှု သို့မဟုတ် စစ်ထုတ်ချက်များကို ပြင်ဆင်ကြည့်ပါ။", + ["report.noIssuesFound"] = "ပြဿနာမတွေ့ပါ", + ["report.noIssuesFoundDesc"] = "လက်ရှိအစီရင်ခံ စစ်ထုတ်ချက်များနှင့် ကိုက်ညီသော ပြဿနာ မရှိပါ။", + ["report.healthCritical"] = "အရေးကြီး", + ["report.healthAtRisk"] = "အန္တရာယ်ရှိ", + ["report.healthGood"] = "ကောင်းမွန်", + ["report.delay"] = "နှောင့်နှေးချက်", + ["report.blockedBy"] = "ပိတ်ဆို့နေသည်မှာ", + + ["validation.startDateRequired"] = "စတင်ရက် လိုအပ်ပါသည်။", + ["validation.dueDateRequired"] = "ပြီးဆုံးရက် လိုအပ်ပါသည်။", + ["validation.dueDateBeforeStart"] = "ပြီးဆုံးရက်သည် စတင်ရက်ထက် စော၍ မရပါ။", + ["validation.projectNameRequired"] = "စီမံကိန်းအမည် လိုအပ်ပါသည်။", + ["validation.taskTitleRequired"] = "လုပ်ငန်းခေါင်းစဉ် လိုအပ်ပါသည်။", + ["validation.selectProjectRequired"] = "စီမံကိန်းတစ်ခုကို ရွေးပါ။", + ["validation.roleNameRequired"] = "အခန်းကဏ္ဍအမည် လိုအပ်ပါသည်။", + ["validation.passwordMinLength"] = "စကားဝှက်မှာ အနည်းဆုံး ၆ လုံး ရှိရမည်။", + ["validation.usernameMinLength"] = "အသုံးပြုသူအမည်မှာ အနည်းဆုံး ၃ လုံး ရှိရမည်။", + ["validation.usernameNoSpaces"] = "အသုံးပြုသူအမည်တွင် space မပါရပါ။", + ["validation.usernameInvalidCharacters"] = "အသုံးပြုသူအမည်တွင် space မပါရပါ၊ စာလုံး၊ နံပါတ်၊ underscore (_) နှင့် dot (.) သာ အသုံးပြုနိုင်သည်။", + ["validation.passwordMinLengthRule"] = "စကားဝှက်မှာ အနည်းဆုံး ၈ လုံး ရှိရမည်။", + ["validation.passwordComplexityRule"] = "စကားဝှက်တွင် အနည်းဆုံး uppercase တစ်လုံး၊ lowercase တစ်လုံး၊ နံပါတ်တစ်လုံးနှင့် အထူးလက္ခဏာတစ်လုံး ပါရမည်။" + }; + + public static bool IsBurmeseCulture => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.Equals("my", StringComparison.OrdinalIgnoreCase); + + public static string Text(string key, string fallback = "") + { + var catalog = IsBurmeseCulture ? Burmese : English; + if (catalog.TryGetValue(key, out var value)) + { + return value; + } + + if ((IsBurmeseCulture ? English : Burmese).TryGetValue(key, out value)) + { + return value; + } + + return string.IsNullOrWhiteSpace(fallback) ? key : fallback; + } + + public static string Menu(string menuCode, string fallback = "") => Text($"menu.{menuCode}", fallback); + + public static string PageTitle(string key, string fallback = "") => Text($"page.{key}", fallback); + + public static string PageDescription(string key, string fallback = "") => Text($"page.{key}.desc", fallback); + + public static string StatusLabel(AppTaskStatus status) => status switch + { + AppTaskStatus.InProgress => Text("status.inprogress", "In Progress"), + AppTaskStatus.Done => Text("status.done", "Done"), + _ => Text("status.todo", "To Do") + }; + + public static string PriorityLabel(TaskPriority priority) => priority switch + { + TaskPriority.High => Text("priority.high", "High"), + TaskPriority.Medium => Text("priority.medium", "Medium"), + _ => Text("priority.low", "Low") + }; + + public static string EscalationLabel(int level) => level switch + { + 1 => IsBurmeseCulture ? "နိမ့်" : "Low", + 2 => IsBurmeseCulture ? "အလယ်" : "Medium", + 3 => IsBurmeseCulture ? "မြင့်" : "High", + _ => Text("common.none", "None") + }; +} diff --git a/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs b/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..5f3fe35c250469c50c1d692d33862f9e2a218f70 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.AuditLog +{ + public class AuditLogDto + { + public long Id { get; set; } + public long? UserId { get; set; } + public string Username { get; set; } = null!; + public string UserFullName { get; set; } = null!; + public string Action { get; set; } = null!; + public string Module { get; set; } = null!; + public string Description { get; set; } = null!; + public string? IpAddress { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Auth/AuthResponse.cs b/TaskTrackingSystem.Shared/Models/Auth/AuthResponse.cs new file mode 100644 index 0000000000000000000000000000000000000000..5da16046873d59e8e21c9f94b666b1f6076a7a5c --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Auth/AuthResponse.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.Auth +{ + public class AuthResponseDto + { + public string Token { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public long RoleId { get; set; } + public string RoleName { get; set; } = string.Empty; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Auth/LoginDto.cs b/TaskTrackingSystem.Shared/Models/Auth/LoginDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..81e291361f2fcdd89b87b4d78158821a4da226d0 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Auth/LoginDto.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.Auth +{ + public class LoginDto + { + [Required] + public string UsernameOrEmail { get; set; } = string.Empty; // Username သို့မဟုတ် Email ကြိုက်တာနဲ့ login ဝင်လို့ရအောင် ပြုလုပ်ထားခြင်း + + [Required] + public string Password { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/TaskTrackingSystem.Shared/Models/Auth/RegisterDto.cs b/TaskTrackingSystem.Shared/Models/Auth/RegisterDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..c8ba23827788501c7cc0da8f573fe857043d1997 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Auth/RegisterDto.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Auth +{ + public class RegisterDto + { + [Required, MaxLength(50)] + [MinLength(3, ErrorMessage = ResultMessages.UsernameMinLength)] + [RegularExpression(@"^[a-zA-Z0-9._]+$", ErrorMessage = ResultMessages.UsernameInvalidCharacters)] + public string Username { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(256)] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(8, ErrorMessage = ResultMessages.PasswordMinLengthRule)] + [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$", ErrorMessage = ResultMessages.PasswordComplexityRule)] + public string Password { get; set; } = string.Empty; + + [MaxLength(20)] + public string? Phone { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Auth/ResetPasswordDto.cs b/TaskTrackingSystem.Shared/Models/Auth/ResetPasswordDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b99a4ef36971d95288eca957090f9839d63bb908 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Auth/ResetPasswordDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Auth +{ + public class ResetPasswordDto + { + [Required] + public string UsernameOrEmail { get; set; } = string.Empty; + + [Required] + public string RecoveryCode { get; set; } = string.Empty; + + [Required] + [MinLength(8, ErrorMessage = ResultMessages.PasswordMinLengthRule)] + [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$", ErrorMessage = ResultMessages.PasswordComplexityRule)] + public string NewPassword { get; set; } = string.Empty; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Comment/CommentDto.cs b/TaskTrackingSystem.Shared/Models/Comment/CommentDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..6738de8a9cba7fc022b625cc9c74d3127237910e --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Comment/CommentDto.cs @@ -0,0 +1,21 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Comment +{ + public class CommentDto + { + public long Id { get; set; } + public long TaskId { get; set; } + public long UserId { get; set; } + public string UserFullName { get; set; } = null!; + public string UserRoleName { get; set; } = null!; + public string Message { get; set; } = null!; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } + + public class CreateCommentDto + { + public string Message { get; set; } = null!; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Dashboard/DashboardSummaryDto.cs b/TaskTrackingSystem.Shared/Models/Dashboard/DashboardSummaryDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..4dbf9f9fc9421db0f7602e03201c2c4cec6da4a2 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Dashboard/DashboardSummaryDto.cs @@ -0,0 +1,9 @@ +namespace TaskTrackingSystem.Shared.Models.Dashboard +{ + public class DashboardSummaryDto + { + public int TotalUsers { get; set; } + public int ActiveProjectsCount { get; set; } + public int PendingTasksCount { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Dashboard/ProjectProgressDto.cs b/TaskTrackingSystem.Shared/Models/Dashboard/ProjectProgressDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..7fc31b17343eb5c09cdbee49c1b1d53bdd531721 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Dashboard/ProjectProgressDto.cs @@ -0,0 +1,11 @@ +namespace TaskTrackingSystem.Shared.Models.Dashboard +{ + public class ProjectProgressDto + { + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public double CompletionPercentage { get; set; } + public int CompletedTasksCount { get; set; } + public int TotalTasksCount { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs b/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..40f606b3a101728b220d72e5d5797333f66be948 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs @@ -0,0 +1,12 @@ +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Dashboard +{ + public class TaskStatusOverviewDto + { + public string StatusName { get; set; } = string.Empty; + public AppTaskStatus StatusId { get; set; } + public int TaskCount { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs b/TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..654ab3b7fab460ca823602f10e02f5ebebb0220e --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Localization; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Issue +{ + public class CreateIssueDto : IValidatableObject + { + [Required] + [Range(1, long.MaxValue)] + public long TaskId { get; set; } + + [Required, MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(1000)] + public string? Description { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedTo { get; set; } + + [Range(typeof(decimal), "0", "100000")] + public decimal? EstimatedHours { get; set; } + + [Range(typeof(decimal), "0", "100000")] + public decimal? ActualHours { get; set; } + + [MaxLength(300)] + public string? DelayReason { get; set; } + + public bool IsBlocked { get; set; } + + [MaxLength(200)] + public string? BlockedBy { get; set; } + + [Range(0, 3)] + public int EscalationLevel { get; set; } + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime DueDate { get; set; } + + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } + + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (StartDate == default) + { + yield return new ValidationResult(AppLocalization.Text("validation.startDateRequired", "Start date is required."), new[] { nameof(StartDate) }); + } + + if (DueDate == default) + { + yield return new ValidationResult(AppLocalization.Text("validation.dueDateRequired", "Due date is required."), new[] { nameof(DueDate) }); + } + + if (StartDate != default && DueDate != default && DueDate.Date < StartDate.Date) + { + yield return new ValidationResult(AppLocalization.Text("validation.dueDateBeforeStart", "Due date cannot be earlier than start date."), new[] { nameof(DueDate), nameof(StartDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs b/TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..5f2c6eec8df6b4ea5141b068eb18cb2506b5348b --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs @@ -0,0 +1,30 @@ +using System; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.Shared.Models.Issue +{ + public class IssueDto + { + public long Id { get; set; } + public long TaskId { get; set; } + public string TaskTitle { get; set; } = string.Empty; + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public long? AssignedTo { get; set; } + public string? AssignedToName { get; set; } + public decimal? EstimatedHours { get; set; } + public decimal? ActualHours { get; set; } + public string? DelayReason { get; set; } + public bool IsBlocked { get; set; } + public string? BlockedBy { get; set; } + public int EscalationLevel { get; set; } + public DateTime StartDate { get; set; } + public DateTime DueDate { get; set; } + public AppTaskStatus StatusId { get; set; } + public TaskPriority PriorityId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs b/TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..6a4d110f5cb18a2ceea652bf62ac694590d8946c --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Localization; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Issue +{ + public class UpdateIssueDto : IValidatableObject + { + [Required, MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(1000)] + public string? Description { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedTo { get; set; } + + [Range(typeof(decimal), "0", "100000")] + public decimal? EstimatedHours { get; set; } + + [Range(typeof(decimal), "0", "100000")] + public decimal? ActualHours { get; set; } + + [MaxLength(300)] + public string? DelayReason { get; set; } + + public bool IsBlocked { get; set; } + + [MaxLength(200)] + public string? BlockedBy { get; set; } + + [Range(0, 3)] + public int EscalationLevel { get; set; } + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime DueDate { get; set; } + + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } + + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (StartDate == default) + { + yield return new ValidationResult(AppLocalization.Text("validation.startDateRequired", "Start date is required."), new[] { nameof(StartDate) }); + } + + if (DueDate == default) + { + yield return new ValidationResult(AppLocalization.Text("validation.dueDateRequired", "Due date is required."), new[] { nameof(DueDate) }); + } + + if (StartDate != default && DueDate != default && DueDate.Date < StartDate.Date) + { + yield return new ValidationResult(AppLocalization.Text("validation.dueDateBeforeStart", "Due date cannot be earlier than start date."), new[] { nameof(DueDate), nameof(StartDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Menu/AccessMenuDto.cs b/TaskTrackingSystem.Shared/Models/Menu/AccessMenuDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..c1eaf5bd8226f2e6a1e8f6923e759076eb16c844 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/AccessMenuDto.cs @@ -0,0 +1,15 @@ +namespace TaskTrackingSystem.Shared.Models.Menu +{ + public class AccessMenuDto + { + public string MenuCode { get; set; } = string.Empty; + public string ParentCode { get; set; } = string.Empty; + public string MenuName { get; set; } = string.Empty; + public string? MenuUrl { get; set; } + public int OrderNo { get; set; } + public string? Icon { get; set; } + public bool Visible { get; set; } + public System.Collections.Generic.List Permissions { get; set; } = new(); + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Menu/AccessPermissionDto.cs b/TaskTrackingSystem.Shared/Models/Menu/AccessPermissionDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..e4536bb32766a8ee5a07432884728a484cf06a8a --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/AccessPermissionDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Menu +{ + public class AccessPermissionDto + { + public string PermissionId { get; set; } = string.Empty; + public string PermissionCode { get; set; } = string.Empty; + public string ParentMenuCode { get; set; } = string.Empty; + public string ActionName { get; set; } = string.Empty; + public string ApiName { get; set; } = string.Empty; + public bool Visible { get; set; } + public int OrderNo { get; set; } + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Menu/MenuDto.cs b/TaskTrackingSystem.Shared/Models/Menu/MenuDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b5c7f9a9ab2e507a298fe8c83f483f623b12d305 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/MenuDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace TaskTrackingSystem.Shared.Models.Menu +{ + public class MenuDto + { + public string MenuCode { get; set; } = string.Empty; + public string ParentCode { get; set; } = string.Empty; + public string MenuName { get; set; } = string.Empty; + public string? MenuUrl { get; set; } + public int OrderNo { get; set; } + public string? Icon { get; set; } + public List SubMenus { get; set; } = new List(); + } +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..282c90d937ab102fd337047294369b4749b11be7 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs @@ -0,0 +1,18 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Notification; + +public class NotificationDto +{ + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public byte NotificationType { get; set; } + public string SourceType { get; set; } = string.Empty; + public long SourceId { get; set; } + public string TargetUrl { get; set; } = string.Empty; + public bool IsRead { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? ReadAt { get; set; } + public string? SenderName { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationNavigation.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationNavigation.cs new file mode 100644 index 0000000000000000000000000000000000000000..93cd446868cfc1704680a1607001018676dcfa27 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationNavigation.cs @@ -0,0 +1,22 @@ +namespace TaskTrackingSystem.Shared.Models.Notification; + +public static class NotificationNavigation +{ + public static string BuildTargetUrl(string sourceType, long sourceId, byte notificationType) + { + if (string.Equals(sourceType, "project", StringComparison.OrdinalIgnoreCase)) + { + return $"/projects/{sourceId}/tasks"; + } + + if (string.Equals(sourceType, "task", StringComparison.OrdinalIgnoreCase)) + { + var isCommentThread = notificationType is (byte)NotificationType.CommentAdded or (byte)NotificationType.Mention; + return isCommentThread + ? $"/tasks/{sourceId}/details?tab=comments" + : $"/tasks/{sourceId}/details"; + } + + return "/dashboard"; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs new file mode 100644 index 0000000000000000000000000000000000000000..ecae3ec1161bf2d849f8c7ade2522cd05eaff3cc --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs @@ -0,0 +1,14 @@ +namespace TaskTrackingSystem.Shared.Models.Notification; + +public enum NotificationType : byte +{ + TaskAssigned = 1, + StatusChanged = 2, + DueDateReminder = 3, + OverdueAlert = 4, + CommentAdded = 5, + Mention = 6, + PriorityChanged = 7, + ProjectUpdated = 8, + System = 9 +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs b/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..30dc6677c5c385684cd079fd3d2839e82af628a1 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs @@ -0,0 +1,6 @@ +namespace TaskTrackingSystem.Shared.Models.Notification; + +public class RegisterDeviceTokenDto +{ + public string FcmToken { get; set; } = string.Empty; +} diff --git a/TaskTrackingSystem.Shared/Models/Project/AssignMembersDto.cs b/TaskTrackingSystem.Shared/Models/Project/AssignMembersDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..0ec2b6dad6fff9e4cf3bd2963babec71ea2df4c9 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/AssignMembersDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class AssignMembersDto + { + [Required] + public List UserIds { get; set; } = new List(); + } +} diff --git a/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs b/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..ed93c0b278ec3332538f892dadacf6945aca3129 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class CreateProjectDto : IValidatableObject + { + [Required, MaxLength(150)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime EndDate { get; set; } + + [Required] + public long CreatedById { get; set; } + + [Range(0, 100000)] + public int? BudgetedHours { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (EndDate < StartDate) + { + yield return new ValidationResult( + "End date cannot be before start date.", + new[] { nameof(EndDate), nameof(StartDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Project/ProjectDto.cs b/TaskTrackingSystem.Shared/Models/Project/ProjectDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..7f18c0df7f49180ef78ad199c29ec7681145178a --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/ProjectDto.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class ProjectDto + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public long CreatedById { get; set; } + public DateTime CreatedAt { get; set; } + public int? BudgetedHours { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Project/UpdateProjectDto.cs b/TaskTrackingSystem.Shared/Models/Project/UpdateProjectDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..d93fff14a0bbd5d0f18024def99da2fd374b1fdf --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/UpdateProjectDto.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class UpdateProjectDto : IValidatableObject + { + [Required, MaxLength(150)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime EndDate { get; set; } + + [Range(0, 100000)] + public int? BudgetedHours { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (EndDate < StartDate) + { + yield return new ValidationResult( + "End date cannot be before start date.", + new[] { nameof(EndDate), nameof(StartDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..597fe97f734dbc082f33bf7783aff078855c79f2 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs @@ -0,0 +1,20 @@ +namespace TaskTrackingSystem.Shared.Models.Report; + +public class EmployeeProductivityReportDto +{ + public long UserId { get; set; } + public string FullName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public int AssignedCount { get; set; } + public int CompletedCount { get; set; } + public int AssignedIssues { get; set; } + public int OpenIssues { get; set; } + public int OverdueIssues { get; set; } + public decimal ActualHours { get; set; } + public string WorkloadLevel { get; set; } = "Balanced"; + public double AverageCompletionTimeDays { get; set; } + public string Trend { get; set; } = "Stable"; + public double CompletionRate { get; set; } + public double Efficiency { get; set; } + public double OnTimeDeliveryRate { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/IssueReportSummaryDto.cs b/TaskTrackingSystem.Shared/Models/Report/IssueReportSummaryDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..f97ac81bd324a421b32178f9e406b553e529c315 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/IssueReportSummaryDto.cs @@ -0,0 +1,13 @@ +namespace TaskTrackingSystem.Shared.Models.Report; + +public class IssueReportSummaryDto +{ + public int TotalIssues { get; set; } + public int OpenIssues { get; set; } + public int OverdueIssues { get; set; } + public int BlockedIssues { get; set; } + public decimal EstimatedHours { get; set; } + public decimal ActualHours { get; set; } + public decimal VarianceHours { get; set; } + public double UtilizationPercent { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs b/TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..acd1aef25271448748aefa42aa37994b46ffdaa9 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class OverdueCriticalTaskDto + { + public long TaskId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string StatusName { get; set; } = string.Empty; + public string PriorityName { get; set; } = string.Empty; + public string? AssignedTo { get; set; } + public long? AssignedToUserId { get; set; } + public DateTime DueDate { get; set; } + public int DaysOverdue { get; set; } + public DateTime CreatedAt { get; set; } + public int OverdueIssues { get; set; } + public string DelayReason { get; set; } = string.Empty; + public string BlockedBy { get; set; } = string.Empty; + public int EscalationLevel { get; set; } + public string RecordType { get; set; } = "Task"; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..4720b10009cc32a86f1179276b9b9b1c8ad209f0 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs @@ -0,0 +1,21 @@ +namespace TaskTrackingSystem.Shared.Models.Report; + +public class ProjectProgressReportDto +{ + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public int TotalTasks { get; set; } + public int CompletedTasks { get; set; } + public int OpenIssues { get; set; } + public int OverdueIssues { get; set; } + public int BlockedIssues { get; set; } + public double Progress { get; set; } + public bool IsAhead { get; set; } + public bool IsAtRisk { get; set; } + public string ProjectHealth { get; set; } = "Healthy"; + public DateTime LastActivity { get; set; } + public DateTime? NextMilestone { get; set; } + public decimal ActualHours { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..48814c2198280f7a9135a5a628b8ebd6c05d96f2 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs @@ -0,0 +1,29 @@ +using System; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class TaskReportDto + { + public long TaskId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string ProjectName { get; set; } = string.Empty; + public long ProjectId { get; set; } + public AppTaskStatus StatusId { get; set; } + public string StatusName { get; set; } = string.Empty; + public TaskPriority PriorityId { get; set; } + public string PriorityName { get; set; } = string.Empty; + public string? AssignedToUser { get; set; } + public long? AssignedToUserId { get; set; } + public string? AssignedByUser { get; set; } + public DateTime DueDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime LastActivity { get; set; } + public int OpenIssues { get; set; } + public int OverdueIssues { get; set; } + public string IssueSummary { get; set; } = string.Empty; + public string TaskHealth { get; set; } = "Healthy"; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/TaskStatusSummaryDto.cs b/TaskTrackingSystem.Shared/Models/Report/TaskStatusSummaryDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..77ca2cec85c23e81ac2715fdf68a6c6cee51b702 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TaskStatusSummaryDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class TaskStatusSummaryDto + { + public long TaskId { get; set; } + public string Title { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public string StatusName { get; set; } = string.Empty; + public string PriorityName { get; set; } = string.Empty; + public string? AssignedTo { get; set; } + public DateTime DueDate { get; set; } + public DateTime CreatedAt { get; set; } + public bool IsOverdue => DueDate < DateTime.UtcNow && StatusName != "Done"; + } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/TeamProductivityReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/TeamProductivityReportDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..c3793d0081de3a6c3c2fc367addd8e0db999ecba --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TeamProductivityReportDto.cs @@ -0,0 +1,15 @@ +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class TeamProductivityReportDto + { + public long UserId { get; set; } + public string FullName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public int TotalAssigned { get; set; } + public int Completed { get; set; } + public int InProgress { get; set; } + public int ToDo { get; set; } + public int Overdue { get; set; } + public double CompletionRate { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Report/UserProductivityDto.cs b/TaskTrackingSystem.Shared/Models/Report/UserProductivityDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..e281345524d0fb8bc18d0f4be7562f539d4d855d --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/UserProductivityDto.cs @@ -0,0 +1,13 @@ +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class UserProductivityDto + { + public long UserId { get; set; } + public string Username { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public int TotalAssignedTasks { get; set; } + public int CompletedTasksCount { get; set; } + public double EfficiencyRatio { get; set; } // Completed / Total Asssined (percentage or ratio) + public double OnTimeDeliveryRate { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Role/AssignAccessDto.cs b/TaskTrackingSystem.Shared/Models/Role/AssignAccessDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..44c19cbbc4fcfd5754aebabb9682bc294fe49295 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/AssignAccessDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class AssignAccessDto + { + [Required] + /// + /// Menu codes and permission codes selected for the role. + /// + public List AccessCodes { get; set; } = new List(); + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs b/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..56a50e52c9cddf596afef421a53fce3c32d6b409 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class CreateRoleDto + { + [Required, MaxLength(50)] + public string Name { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Description { get; set; } + + /// + /// Optional: Menu and permission codes to assign to this role on creation. + /// + public List AccessCodes { get; set; } = new(); + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs b/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..d8bed3d9343b3fc0a82dc66cca604868f519acff --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs @@ -0,0 +1,12 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class RoleDto + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Role/UpdateRoleDto.cs b/TaskTrackingSystem.Shared/Models/Role/UpdateRoleDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b1a3c3bdde4fe95ee24ae86d669bf20a599f4b5b --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/UpdateRoleDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class UpdateRoleDto + { + [Required, MaxLength(50)] + public string Name { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Description { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..d5dd20ceb9ffdbcc6e42a5ff092a2f55165ec6aa --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Task +{ + public class CreateTaskDto : IValidatableObject + { + [Required, MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(1000)] + public string? Description { get; set; } + + [Required] + [Range(1, long.MaxValue)] + public long ProjectId { get; set; } + + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } + + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedTo { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedBy { get; set; } + + [Required] + public DateTime DueDate { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (DueDate == default) + { + yield return new ValidationResult( + "Due date is required.", + new[] { nameof(DueDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..d6fd677b5398ffa7a8425b1f92ec7ef48df5345d --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Task +{ + public class TaskDto + { + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public long ProjectId { get; set; } + public AppTaskStatus StatusId { get; set; } + public TaskPriority PriorityId { get; set; } + public long? AssignedTo { get; set; } + public long? AssignedBy { get; set; } + public DateTime DueDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public bool IsArchived { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..1b09b89f57e5c4f209cf7fb0dc54d0d99acf3105 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + +namespace TaskTrackingSystem.Shared.Models.Task +{ + public class UpdateTaskDto : IValidatableObject + { + [Required, MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(1000)] + public string? Description { get; set; } + + [Required] + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } + + [Required] + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedTo { get; set; } + + [Range(0, long.MaxValue)] + public long? AssignedBy { get; set; } + + [Required] + public DateTime DueDate { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (DueDate == default) + { + yield return new ValidationResult( + "Due date is required.", + new[] { nameof(DueDate) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/User/CreateUserDto.cs b/TaskTrackingSystem.Shared/Models/User/CreateUserDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..683e10c6acfdb2906c885f90522ef45978399779 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/CreateUserDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.User +{ + public class CreateUserDto + { + [Required, MaxLength(50)] + [MinLength(3, ErrorMessage = ResultMessages.UsernameMinLength)] + [RegularExpression(@"^[a-zA-Z0-9._]+$", ErrorMessage = ResultMessages.UsernameInvalidCharacters)] + public string Username { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string LastName { get; set; } = string.Empty; + + [Required, EmailAddress, MaxLength(256)] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(8, ErrorMessage = ResultMessages.PasswordMinLengthRule)] + [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$", ErrorMessage = ResultMessages.PasswordComplexityRule)] + public string Password { get; set; } = string.Empty; + + [MaxLength(20)] + public string? Phone { get; set; } + + [Required] + public long RoleId { get; set; } + } + } diff --git a/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs b/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..812ddd01976efc0837b5e0a59a15d1edf976fe5b --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.User +{ + public class UpdateUserDto + { + [Required, MaxLength(50)] + [MinLength(3, ErrorMessage = ResultMessages.UsernameMinLength)] + [RegularExpression(@"^[a-zA-Z0-9._]+$", ErrorMessage = ResultMessages.UsernameInvalidCharacters)] + public string Username { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string FirstName { get; set; } = string.Empty; + + [Required, MaxLength(50)] + public string LastName { get; set; } = string.Empty; + + [MaxLength(20)] + public string? Phone { get; set; } + + [Required] + public long RoleId { get; set; } + + public bool IsActive { get; set; } + } +} diff --git a/TaskTrackingSystem.Shared/Models/User/UserDto.cs b/TaskTrackingSystem.Shared/Models/User/UserDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..89073dbedd0b264da3a9f9a2530fae2d4691f9ea --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/UserDto.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TaskTrackingSystem.Shared.Models.User +{ + public class UserDto + { + public long Id { get; set; } + public string Username { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string? Phone { get; set; } + public long RoleId { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + +} diff --git a/TaskTrackingSystem.Shared/PagedResult.cs b/TaskTrackingSystem.Shared/PagedResult.cs new file mode 100644 index 0000000000000000000000000000000000000000..f63a92a40d4d9f6725ac1c9134fe31d48156ac6e --- /dev/null +++ b/TaskTrackingSystem.Shared/PagedResult.cs @@ -0,0 +1,10 @@ +namespace TaskTrackingSystem.Shared; + +public class PagedResult +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public int TotalPages { get; set; } +} diff --git a/TaskTrackingSystem.Shared/PaginationQuery.cs b/TaskTrackingSystem.Shared/PaginationQuery.cs new file mode 100644 index 0000000000000000000000000000000000000000..d95a94e854b409c6f494502fee68bc0c647e1ff7 --- /dev/null +++ b/TaskTrackingSystem.Shared/PaginationQuery.cs @@ -0,0 +1,8 @@ +namespace TaskTrackingSystem.Shared; + +public class PaginationQuery +{ + public int? Page { get; set; } + + public int? Limit { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Result.cs b/TaskTrackingSystem.Shared/Result.cs new file mode 100644 index 0000000000000000000000000000000000000000..7dfc64e4368a5823279e546bda81b8bf590490f5 --- /dev/null +++ b/TaskTrackingSystem.Shared/Result.cs @@ -0,0 +1,111 @@ +using System; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.Shared +{ + public class Result + { + public bool IsSuccess { get; set; } + public T? Value { get; set; } + public string? ErrorMessage { get; set; } + public int StatusCode { get; set; } + + public static Result Success(T value, int statusCode = 200) + { + return new Result + { + IsSuccess = true, + Value = value, + StatusCode = statusCode + }; + } + + public static Result Failure(string errorMessage, int statusCode = 400) + { + return new Result + { + IsSuccess = false, + ErrorMessage = errorMessage, + StatusCode = statusCode + }; + } + } + + public class Result + { + public bool IsSuccess { get; set; } + public string? ErrorMessage { get; set; } + public int StatusCode { get; set; } + + public static Result Success(int statusCode = 200) + { + return new Result + { + IsSuccess = true, + StatusCode = statusCode + }; + } + + public static Result Failure(string errorMessage, int statusCode = 400) + { + return new Result + { + IsSuccess = false, + ErrorMessage = errorMessage, + StatusCode = statusCode + }; + } + } + + public static class ResultMessages + { + // Auth + public const string InvalidCredentials = "Invalid username/email or password."; + public const string ParseAuthResponseFailed = "Failed to parse authentication response tokens."; + + // Project + public const string ProjectNameRequired = "Project name is required."; + public const string FailedToUpdateProject = "Failed to update project."; + public const string FailedToCreateProject = "Failed to create project."; + public static string ProjectNotFound(long id) => AppLocalization.Text("result.projectNotFound", $"Project with ID {id} not found.").Replace("{id}", id.ToString()); + public const string UserIdsCannotBeNull = "User IDs cannot be null."; + public static string InvalidUserIds(string ids) => $"The following user IDs are invalid or deleted: {ids}"; + public static string UserNotProjectMember(long userId, long projectId) => $"User with ID {userId} is not a member of project with ID {projectId}."; + + // Task + public const string TaskTitleRequired = "Task title is required."; + public const string SelectProjectRequired = "Please select a project."; + public const string FailedToUpdateTask = "Failed to update task."; + public const string FailedToCreateTask = "Failed to create task."; + public static string TaskNotFound(long id) => AppLocalization.Text("result.taskNotFound", $"Task with ID {id} not found.").Replace("{id}", id.ToString()); + public static string TaskNotFoundOrDeleted(long id) => AppLocalization.Text("result.taskNotFoundOrDeleted", $"Task with ID {id} not found or already deleted.").Replace("{id}", id.ToString()); + + // Role/Permission + public const string RoleNameRequired = "Role name is required."; + public const string FailedToUpdateRole = "Failed to update role."; + public const string FailedToCreateRole = "Failed to create role."; + public static string RoleNotFound(long id) => AppLocalization.Text("result.roleNotFound", $"Role with ID {id} not found.").Replace("{id}", id.ToString()); + public static string AccessCodesCannotBeNull => AppLocalization.Text("result.accessCodesCannotBeNull", "Access codes cannot be null."); + public static string InvalidAccessCodes(string ids) => AppLocalization.Text("result.invalidAccessCodes", $"The following access codes are invalid or deleted: {ids}").Replace("{ids}", ids); + + // User + public const string FillAllFields = "Please fill in all required fields."; + public const string FailedToUpdateUser = "Failed to update user."; + public const string PasswordMinLength = "Password must be at least 6 characters."; + public const string FailedToCreateUser = "Failed to create user."; + public static string UserNotFound(long id) => AppLocalization.Text("result.userNotFound", $"User with ID {id} not found.").Replace("{id}", id.ToString()); + + // Register + public const string ParseRegistrationResponseFailed = "Failed to parse registration response."; + public const string RegistrationFailed = "Registration failed. Please try again."; + + // Username Validation Rules + public const string UsernameMinLength = "Username must be at least 3 characters long."; + public const string UsernameNoSpaces = "Username cannot contain spaces."; + public const string UsernameInvalidCharacters = "Username cannot contain spaces, and can only contain letters, numbers, underscores (_), and periods (.)."; + + // Password Validation Rules + public const string PasswordMinLengthRule = "Password must be at least 8 characters long."; + public const string PasswordComplexityRule = "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."; + } +} diff --git a/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs b/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs new file mode 100644 index 0000000000000000000000000000000000000000..54f80d6e0c6961577c2d85338f4129e853413e66 --- /dev/null +++ b/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs @@ -0,0 +1,205 @@ +using System.Text; + +namespace TaskTrackingSystem.Shared; + +public static class SimplePdfReportBuilder +{ + private const int PageWidth = 612; + private const int PageHeight = 792; + private const int LeftMargin = 48; + private const int TopMargin = 52; + private const int BottomMargin = 48; + private const int TitleFontSize = 16; + private const int BodyFontSize = 10; + private const int LineSpacing = 14; + private const int BodyLinesPerPage = 42; + + public static byte[] BuildTableReport( + string title, + IEnumerable? summaryLines, + IReadOnlyList headers, + IEnumerable> rows) + { + var lines = new List(); + + if (summaryLines != null) + { + foreach (var line in summaryLines.Where(line => !string.IsNullOrWhiteSpace(line))) + { + lines.AddRange(WrapLine(line.Trim())); + } + } + + if (headers.Count > 0) + { + lines.Add(string.Join(" | ", headers)); + lines.Add(new string('-', Math.Min(96, Math.Max(24, headers.Sum(h => h.Length + 3))))); + } + + foreach (var row in rows) + { + lines.AddRange(WrapLine(string.Join(" | ", row.Select(NormalizeCell)))); + } + + if (lines.Count == 0) + { + lines.Add("No data available."); + } + + return BuildTextReport(title, lines); + } + + public static byte[] BuildTextReport(string title, IEnumerable bodyLines) + { + var normalizedLines = bodyLines + .SelectMany(line => WrapLine(line)) + .ToList(); + + var pages = normalizedLines + .Chunk(BodyLinesPerPage) + .Select(chunk => chunk.ToList()) + .ToList(); + + if (pages.Count == 0) + { + pages.Add(new List { "No data available." }); + } + + var objects = new List(); + objects.Add("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + + var pageObjectIds = new List(); + var contentObjectIds = new List(); + var nextObjectId = 4; + + foreach (var _ in pages) + { + pageObjectIds.Add(nextObjectId++); + contentObjectIds.Add(nextObjectId++); + } + + var pagesKids = string.Join(" ", pageObjectIds.Select(id => $"{id} 0 R")); + objects.Add($"2 0 obj\n<< /Type /Pages /Kids [{pagesKids}] /Count {pages.Count} >>\nendobj\n"); + objects.Add("3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + + for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++) + { + var pageObjectId = pageObjectIds[pageIndex]; + var contentObjectId = contentObjectIds[pageIndex]; + var pageLines = pages[pageIndex]; + var content = BuildContentStream(title, pageLines, pageIndex + 1, pages.Count); + var contentBytes = Encoding.ASCII.GetBytes(content); + + objects.Add( + $"{pageObjectId} 0 obj\n" + + $"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PageWidth} {PageHeight}] /Resources << /Font << /F1 3 0 R >> >> /Contents {contentObjectId} 0 R >>\n" + + "endobj\n"); + + objects.Add( + $"{contentObjectId} 0 obj\n" + + $"<< /Length {contentBytes.Length} >>\n" + + "stream\n" + + content + + "\nendstream\nendobj\n"); + } + + var buffer = new StringBuilder(); + buffer.Append("%PDF-1.4\n"); + + var offsets = new List { 0 }; + foreach (var obj in objects) + { + offsets.Add(Encoding.ASCII.GetByteCount(buffer.ToString())); + buffer.Append(obj); + } + + var xrefStart = Encoding.ASCII.GetByteCount(buffer.ToString()); + var xref = new StringBuilder(); + xref.Append("xref\n"); + xref.Append($"0 {objects.Count + 1}\n"); + xref.Append("0000000000 65535 f \n"); + + foreach (var offset in offsets.Skip(1)) + { + xref.Append($"{offset:D10} 00000 n \n"); + } + + xref.Append("trailer\n"); + xref.Append($"<< /Size {objects.Count + 1} /Root 1 0 R >>\n"); + xref.Append("startxref\n"); + xref.Append($"{xrefStart}\n"); + xref.Append("%%EOF"); + + buffer.Append(xref); + return Encoding.ASCII.GetBytes(buffer.ToString()); + } + + private static string BuildContentStream(string title, IReadOnlyList lines, int pageNumber, int totalPages) + { + var content = new StringBuilder(); + content.AppendLine("BT"); + content.AppendLine("/F1 16 Tf"); + content.AppendLine($"{LeftMargin} {PageHeight - TopMargin} Td"); + content.AppendLine($"({EscapeText(title)}) Tj"); + content.AppendLine("/F1 10 Tf"); + content.AppendLine($"0 -22 Td"); + content.AppendLine($"({EscapeText($"Page {pageNumber} of {totalPages}")}) Tj"); + + foreach (var line in lines) + { + content.AppendLine($"0 -{LineSpacing} Td"); + content.AppendLine($"({EscapeText(line)}) Tj"); + } + + content.AppendLine("ET"); + return content.ToString(); + } + + private static IEnumerable WrapLine(string? line) + { + if (string.IsNullOrWhiteSpace(line)) + { + yield return string.Empty; + yield break; + } + + const int maxWidth = 92; + var remaining = line.Trim(); + + while (remaining.Length > maxWidth) + { + var breakIndex = remaining.LastIndexOf(' ', maxWidth); + if (breakIndex <= 0) + { + breakIndex = maxWidth; + } + + yield return remaining[..breakIndex].Trim(); + remaining = remaining[breakIndex..].TrimStart(); + } + + if (!string.IsNullOrWhiteSpace(remaining)) + { + yield return remaining; + } + } + + private static string NormalizeCell(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "-"; + } + + return value.Replace("\r", " ").Replace("\n", " ").Trim(); + } + + private static string EscapeText(string value) + { + var ascii = new string(value.Select(ch => ch is >= ' ' and <= '~' ? ch : '?').ToArray()); + return ascii + .Replace("\\", "\\\\") + .Replace("(", "\\(") + .Replace(")", "\\)"); + } +} diff --git a/TaskTrackingSystem.Shared/SpreadsheetReportBuilder.cs b/TaskTrackingSystem.Shared/SpreadsheetReportBuilder.cs new file mode 100644 index 0000000000000000000000000000000000000000..413c95fa74143e51a3528edd846ddddc2ecd5799 --- /dev/null +++ b/TaskTrackingSystem.Shared/SpreadsheetReportBuilder.cs @@ -0,0 +1,348 @@ +using System.Globalization; +using System.IO.Compression; +using System.Security; +using System.Text; +using System.Xml; + +namespace TaskTrackingSystem.Shared; + +public static class SpreadsheetReportBuilder +{ + private const string DateFormat = "dd-MM-yyyy"; + private const string ContentTypeWorkbook = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; + private const string ContentTypeWorksheet = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; + private const string ContentTypeStyles = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; + + public static byte[] BuildTableWorkbook( + string worksheetName, + string title, + IReadOnlyList headers, + IEnumerable> rows, + IEnumerable? summaryLines = null) + { + var rowData = rows.Select(row => row.ToArray()).ToList(); + var summary = summaryLines? + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => line.Trim()) + .ToList() ?? new List(); + + var totalRows = 0; + if (!string.IsNullOrWhiteSpace(title)) + { + totalRows++; + } + + totalRows += summary.Count; + totalRows++; // blank row before header + totalRows++; // header row + totalRows += rowData.Count; + + var columnCount = Math.Max(1, headers.Count); + var lastColumnName = ToColumnName(columnCount); + var lastRowNumber = Math.Max(1, totalRows); + var dimension = $"A1:{lastColumnName}{lastRowNumber}"; + + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "[Content_Types].xml", BuildContentTypesXml()); + AddEntry(archive, "_rels/.rels", BuildRootRelsXml()); + AddEntry(archive, "xl/workbook.xml", BuildWorkbookXml(worksheetName)); + AddEntry(archive, "xl/_rels/workbook.xml.rels", BuildWorkbookRelsXml()); + AddEntry(archive, "xl/styles.xml", BuildStylesXml()); + AddEntry(archive, "xl/worksheets/sheet1.xml", BuildWorksheetXml(title, headers, rowData, summary, dimension)); + } + + return stream.ToArray(); + } + + private static void AddEntry(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } + + private static string BuildContentTypesXml() => + """ + + + + + + + + + """; + + private static string BuildRootRelsXml() => + """ + + + + + """; + + private static string BuildWorkbookXml(string worksheetName) + { + var safeName = EscapeXml(SanitizeWorksheetName(worksheetName)); + return $""" + + + + + + + """; + } + + private static string BuildWorkbookRelsXml() => + """ + + + + + + """; + + private static string BuildStylesXml() => + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private static string BuildWorksheetXml( + string title, + IReadOnlyList headers, + IReadOnlyList rows, + IReadOnlyList summaryLines, + string dimension) + { + var rowXml = new StringBuilder(); + var rowIndex = 1; + var columnCount = Math.Max(1, headers.Count); + + if (!string.IsNullOrWhiteSpace(title)) + { + rowXml.Append(BuildSingleCellRow(rowIndex++, 0, title.Trim(), styleIndex: 1)); + } + + foreach (var line in summaryLines) + { + rowXml.Append(BuildSingleCellRow(rowIndex++, 0, line, styleIndex: 2)); + } + + rowIndex++; // blank row + + rowXml.Append(""); + for (var col = 1; col <= columnCount; col++) + { + var header = col <= headers.Count ? headers[col - 1] : string.Empty; + rowXml.Append(BuildInlineCell(rowIndex, col, header, styleIndex: 1)); + } + rowXml.AppendLine(""); + rowIndex++; + + foreach (var row in rows) + { + rowXml.Append(""); + + for (var col = 1; col <= columnCount; col++) + { + var value = col <= row.Length ? row[col - 1] : null; + rowXml.Append(BuildCell(rowIndex, col, value)); + } + + rowXml.AppendLine(""); + rowIndex++; + } + + var autoFilterRow = 1 + (string.IsNullOrWhiteSpace(title) ? 0 : 1) + summaryLines.Count + 1; + + var worksheetXml = new StringBuilder(); + worksheetXml.AppendLine(""); + worksheetXml.AppendLine(""); + worksheetXml.AppendLine($" "); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine($" "); + worksheetXml.AppendLine($" "); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine(" "); + worksheetXml.Append(BuildColumnDefinitions(columnCount)); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine(" "); + worksheetXml.Append(rowXml); + worksheetXml.AppendLine(" "); + worksheetXml.AppendLine($" "); + worksheetXml.AppendLine(""); + return worksheetXml.ToString(); + } + + private static string BuildSingleCellRow(int rowIndex, int colIndex, string value, int styleIndex) + { + return $"{BuildInlineCell(rowIndex, colIndex + 1, value, styleIndex)}\n"; + } + + private static string BuildColumnDefinitions(int columnCount) + { + var builder = new StringBuilder(); + for (var col = 1; col <= columnCount; col++) + { + builder.Append($" \n"); + } + return builder.ToString(); + } + + private static string BuildCell(int rowIndex, int columnIndex, object? value) + { + if (value == null) + { + return string.Empty; + } + + return value switch + { + DateTime dateTime => BuildNumericCell(rowIndex, columnIndex, dateTime.ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3), + DateTimeOffset dateTimeOffset => BuildNumericCell(rowIndex, columnIndex, dateTimeOffset.DateTime.ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3), + DateOnly dateOnly => BuildNumericCell(rowIndex, columnIndex, dateOnly.ToDateTime(TimeOnly.MinValue).ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3), + bool boolean => BuildBooleanCell(rowIndex, columnIndex, boolean), + sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal + => BuildNumericCell(rowIndex, columnIndex, Convert.ToString(value, CultureInfo.InvariantCulture) ?? "0"), + _ => BuildInlineCell(rowIndex, columnIndex, Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, styleIndex: 0) + }; + } + + private static string BuildInlineCell(int rowIndex, int columnIndex, string value, int styleIndex) + { + var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}"; + return $""" + + {EscapeXml(value)} + + """; + } + + private static string BuildNumericCell(int rowIndex, int columnIndex, string value, int styleIndex = 0) + { + var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}"; + return $"""{value}"""; + } + + private static string BuildBooleanCell(int rowIndex, int columnIndex, bool value) + { + var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}"; + return $"""{(value ? "1" : "0")}"""; + } + + private static string ToColumnName(int columnNumber) + { + var dividend = columnNumber; + var columnName = string.Empty; + + while (dividend > 0) + { + var modulo = (dividend - 1) % 26; + columnName = Convert.ToChar('A' + modulo) + columnName; + dividend = (dividend - modulo) / 26; + } + + return columnName; + } + + private static string SanitizeWorksheetName(string name) + { + var invalid = new[] { '[', ']', ':', '*', '?', '/', '\\' }; + var cleaned = new string(name.Select(ch => invalid.Contains(ch) ? ' ' : ch).ToArray()).Trim(); + if (string.IsNullOrWhiteSpace(cleaned)) + { + cleaned = "Report"; + } + + return cleaned.Length <= 31 ? cleaned : cleaned[..31]; + } + + private static string EscapeXml(string value) => + SecurityElement.Escape(value) ?? string.Empty; +} diff --git a/TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj b/TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj new file mode 100644 index 0000000000000000000000000000000000000000..a95641af451e786b9229c244df3dfec3b399afb7 --- /dev/null +++ b/TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + diff --git a/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs b/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs new file mode 100644 index 0000000000000000000000000000000000000000..6f4549237ce6423e00bdac3310bbca2a52f1b4dc --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.AuditLog; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Audit +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class AuditLogController : ControllerBase + { + private readonly AppDbContext _db; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public AuditLogController(AppDbContext db, PermissionAuthorizationService permissionAuthorizationService) + { + _db = db; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetAuditLogs( + [FromQuery] string? search, + [FromQuery] PaginationQuery? paging = null) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/AuditLog", "List")) + { + return Forbid(); + } + + var query = _db.AuditLogs + .Include(a => a.User) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchLower = search.Trim().ToLower(); + query = query.Where(a => + a.Action.ToLower().Contains(searchLower) || + a.Module.ToLower().Contains(searchLower) || + a.Description.ToLower().Contains(searchLower) || + (a.User != null && (a.User.Username.ToLower().Contains(searchLower) || + (a.User.FirstName + " " + a.User.LastName).ToLower().Contains(searchLower))) || + (a.IpAddress != null && a.IpAddress.ToLower().Contains(searchLower)) + ); + } + + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var fullLogs = await query + .OrderByDescending(a => a.CreatedAt) + .Select(a => new AuditLogDto + { + Id = a.Id, + UserId = a.UserId, + Username = a.User != null ? a.User.Username : "System", + UserFullName = a.User != null ? $"{a.User.FirstName} {a.User.LastName}" : "System", + Action = a.Action, + Module = a.Module, + Description = a.Description, + IpAddress = a.IpAddress, + CreatedAt = a.CreatedAt ?? System.DateTime.UtcNow + }) + .ToListAsync(); + + return Ok(fullLogs); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + + var pagedLogs = await query + .OrderByDescending(a => a.CreatedAt) + .Select(a => new AuditLogDto + { + Id = a.Id, + UserId = a.UserId, + Username = a.User != null ? a.User.Username : "System", + UserFullName = a.User != null ? $"{a.User.FirstName} {a.User.LastName}" : "System", + Action = a.Action, + Module = a.Module, + Description = a.Description, + IpAddress = a.IpAddress, + CreatedAt = a.CreatedAt ?? System.DateTime.UtcNow + }) + .ToPagedResultAsync(page, limit); + + return Ok(pagedLogs); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Auth/AuthController.cs b/TaskTrackingSystem.WebApi/Features/Auth/AuthController.cs new file mode 100644 index 0000000000000000000000000000000000000000..280700e65bf7daaf014aa75f458c6a12cf3fa032 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Auth/AuthController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Auth; + +namespace TaskTrackingSystem.WebApi.Features.Auth +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly AuthService _authService; + + public AuthController(AuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginDto loginDto) + { + try + { + var response = await _authService.LoginAsync(loginDto); + + if (response == null) + { + // Wrap the error inside your official Result architecture + return Unauthorized(Result.Failure(ResultMessages.InvalidCredentials, 401)); + } + + // Wrap the payload inside your official Result architecture + return Ok(Result.Success(response)); + } + catch (InvalidOperationException ex) + { + return BadRequest(Result.Failure(ex.Message, 400)); + } + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterDto registerDto) + { + try + { + var response = await _authService.RegisterAsync(registerDto); + return Ok(Result.Success(response)); + } + catch (InvalidOperationException ex) + { + return BadRequest(Result.Failure(ex.Message, 400)); + } + } + + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordDto resetPasswordDto) + { + var result = await _authService.ResetPasswordAsync(resetPasswordDto); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, result); + } + + return Ok(result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Auth/AuthService.cs b/TaskTrackingSystem.WebApi/Features/Auth/AuthService.cs new file mode 100644 index 0000000000000000000000000000000000000000..cb5a0fed60b64c7d1d14f70be3537ee617709632 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Auth/AuthService.cs @@ -0,0 +1,197 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Auth; +using TaskTrackingSystem.Database; +using TaskTrackingSystem.Database.AppDbContextModels; + +namespace TaskTrackingSystem.WebApi.Features.Auth +{ + public class AuthService + { + private readonly AppDbContext _db; + private readonly IConfiguration _configuration; + private readonly IPasswordHasher _passwordHasher; + + public AuthService(AppDbContext db, IConfiguration configuration, IPasswordHasher passwordHasher) + { + _db = db; + _configuration = configuration; + _passwordHasher = passwordHasher; + } + + public async Task LoginAsync(LoginDto loginDto) + { + + var user = await _db.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => (u.Username == loginDto.UsernameOrEmail || u.Email == loginDto.UsernameOrEmail) + && !u.IsDeleted); + + if (user == null) return null; + + + if (!user.IsActive) + { + throw new InvalidOperationException("This account has been deactivated."); + } + + + var passwordCheck = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, loginDto.Password); + if (passwordCheck == PasswordVerificationResult.Failed) + { + if (!IsLegacyPasswordMatch(user.PasswordHash, loginDto.Password)) + { + return null; + } + + user.PasswordHash = _passwordHasher.HashPassword(user, loginDto.Password); + await _db.SaveChangesAsync(); + } + + var roleId = user.RoleId; + if (roleId <= 0 && user.Role != null) + { + roleId = user.Role.Id; + } + + var roleName = user.Role?.Name ?? string.Empty; + var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, roleId, roleName); + + return new AuthResponseDto + { + Token = token, + Username = user.Username, + Email = user.Email, + RoleId = roleId, + RoleName = roleName + }; + } + + public async Task RegisterAsync(RegisterDto dto) + { + var usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username && !u.IsDeleted); + if (usernameExists) + { + throw new InvalidOperationException("Username is already taken."); + } + + var emailExists = await _db.Users.AnyAsync(u => u.Email == dto.Email && !u.IsDeleted); + if (emailExists) + { + throw new InvalidOperationException("Email is already registered."); + } + + // Find default Role (Employee role, or fallback to lowest ID) + var defaultRole = await _db.Roles.FirstOrDefaultAsync(r => r.Name == "Employee" && r.IsDeleted != true) + ?? await _db.Roles.FirstOrDefaultAsync(r => r.IsDeleted != true); + if (defaultRole == null) + { + throw new InvalidOperationException("No system roles configured. Registration failed."); + } + + var user = new TaskTrackingSystem.Database.AppDbContextModels.User + { + Username = dto.Username, + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + PasswordHash = string.Empty, + Phone = dto.Phone, + RoleId = defaultRole.Id, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password); + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, defaultRole.Id, defaultRole.Name); + + return new AuthResponseDto + { + Token = token, + Username = user.Username, + Email = user.Email, + RoleId = defaultRole.Id, + RoleName = defaultRole.Name + }; + } + + public async Task ResetPasswordAsync(ResetPasswordDto dto) + { + if (string.IsNullOrWhiteSpace(dto.UsernameOrEmail) || + string.IsNullOrWhiteSpace(dto.RecoveryCode) || + string.IsNullOrWhiteSpace(dto.NewPassword)) + { + return Result.Failure(ResultMessages.FillAllFields, 400); + } + + var expectedCode = _configuration["PasswordReset:RecoveryCode"] + ?? throw new InvalidOperationException("PasswordReset:RecoveryCode must be configured."); + if (!string.Equals(dto.RecoveryCode.Trim(), expectedCode, StringComparison.Ordinal)) + { + return Result.Failure("Invalid recovery code.", 400); + } + + var user = await _db.Users + .FirstOrDefaultAsync(u => (u.Username == dto.UsernameOrEmail || u.Email == dto.UsernameOrEmail) && !u.IsDeleted); + + if (user == null) + { + return Result.Failure("Account not found.", 404); + } + + user.PasswordHash = _passwordHasher.HashPassword(user, dto.NewPassword); + user.UpdatedAt = DateTime.UtcNow; + _db.Users.Update(user); + await _db.SaveChangesAsync(); + + return Result.Success(200); + } + + private string GenerateJwtToken(string userId, string username, string email, long roleId, string roleName) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var keyStr = jwtSettings["Key"] + ?? throw new InvalidOperationException("JwtSettings:Key must be configured."); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyStr)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Name, username), + new Claim(ClaimTypes.Email, email), + new Claim("role_id", roleId.ToString()), + new Claim(ClaimTypes.Role, roleName) + }; + + var token = new JwtSecurityToken( + issuer: jwtSettings["Issuer"] + ?? throw new InvalidOperationException("JwtSettings:Issuer must be configured."), + audience: jwtSettings["Audience"] + ?? throw new InvalidOperationException("JwtSettings:Audience must be configured."), + claims: claims, + expires: DateTime.UtcNow.AddDays(7), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static bool IsLegacyPasswordMatch(string passwordHash, string password) + { + return string.Equals(passwordHash, "HASHED_" + password, StringComparison.Ordinal); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardController.cs b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardController.cs new file mode 100644 index 0000000000000000000000000000000000000000..6a4e4a1dad80acb7f2d077fd17567eba785ac599 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Dashboard; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Dashboard +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class DashboardController : ControllerBase + { + private readonly DashboardService _dashboardService; + + public DashboardController(DashboardService dashboardService) + { + _dashboardService = dashboardService; + } + + [HttpGet("summary")] + public async Task>> GetSummary() + { + var result = await _dashboardService.GetSummaryAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("tasks-overview")] + public async Task>>> GetTasksOverview() + { + var result = await _dashboardService.GetTasksOverviewAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("project-progress")] + public async Task>>> GetProjectProgress() + { + var result = await _dashboardService.GetProjectProgressAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs new file mode 100644 index 0000000000000000000000000000000000000000..7b3d08b065a23cb6f08e2747fde416615226c561 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs @@ -0,0 +1,162 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Dashboard; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Dashboard +{ + public class DashboardService + { + private readonly AppDbContext _db; + + public DashboardService(AppDbContext db) + { + _db = db; + } + + public async Task> GetSummaryAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + + var projects = BuildAccessibleProjectQuery(isAdmin, currentUserId); + var tasks = BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId); + + var totalUsers = isAdmin + ? await _db.Users.CountAsync(u => !u.IsDeleted) + : await _db.Users + .Where(u => !u.IsDeleted && + projects.SelectMany(p => p.ProjectMembers.Select(pm => pm.UserId)) + .Distinct() + .Contains(u.Id)) + .CountAsync(); + + var activeProjectsCount = await projects.CountAsync(); + var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != AppTaskStatus.Done); + + var summary = new DashboardSummaryDto + { + TotalUsers = totalUsers, + ActiveProjectsCount = activeProjectsCount, + PendingTasksCount = pendingTasksCount + }; + + return Result.Success(summary); + } + + public async Task>> GetTasksOverviewAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + + var groupedTasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .GroupBy(t => t.StatusId) + .Select(g => new + { + StatusId = g.Key, + Count = g.Count() + }) + .ToListAsync(); + + var statusMap = new Dictionary + { + { AppTaskStatus.Todo, "To Do" }, + { AppTaskStatus.InProgress, "In Progress" }, + { AppTaskStatus.Done, "Done" } + }; + + var overview = groupedTasks.Select(gt => new TaskStatusOverviewDto + { + StatusId = gt.StatusId, + StatusName = statusMap.TryGetValue(gt.StatusId, out var name) ? name : $"Status {gt.StatusId}", + TaskCount = gt.Count + }).ToList(); + + foreach (var status in statusMap) + { + if (!overview.Any(o => o.StatusId == status.Key)) + { + overview.Add(new TaskStatusOverviewDto + { + StatusId = status.Key, + StatusName = status.Value, + TaskCount = 0 + }); + } + } + + return Result>.Success(overview.OrderBy(o => o.StatusId)); + } + + public async Task>> GetProjectProgressAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var activeProjects = await BuildAccessibleProjectQuery(isAdmin, currentUserId).ToListAsync(); + var progressList = new List(); + + foreach (var project in activeProjects) + { + var tasks = await _db.Tasks + .Where(t => t.ProjectId == project.Id && t.IsDeleted != true && !t.IsArchived) + .ToListAsync(); + + int totalTasks = tasks.Count; + int completedTasks = tasks.Count(t => t.StatusId == AppTaskStatus.Done); + double percentage = totalTasks > 0 ? Math.Round(((double)completedTasks / totalTasks) * 100, 2) : 0; + + progressList.Add(new ProjectProgressDto + { + ProjectId = project.Id, + ProjectName = project.Name, + TotalTasksCount = totalTasks, + CompletedTasksCount = completedTasks, + CompletionPercentage = percentage + }); + } + + return Result>.Success(progressList); + } + + private IQueryable BuildAccessibleTaskQuery(bool isAdmin, bool isManager, long currentUserId) + { + var query = _db.Tasks.Where(t => t.IsDeleted != true && !t.IsArchived); + + if (isAdmin) + { + return query; + } + + if (isManager) + { + return query.Where(t => + t.AssignedTo == currentUserId || + t.CreatedBy == currentUserId || + t.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + + return query.Where(t => + t.AssignedTo == currentUserId || + t.CreatedBy == currentUserId); + } + + private IQueryable BuildAccessibleProjectQuery(bool isAdmin, long currentUserId) + { + var query = _db.Projects.Where(p => p.IsDeleted != true); + + if (isAdmin) + { + return query; + } + + return query.Where(p => + p.CreatedById == currentUserId || + p.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Issue/IssueController.cs b/TaskTrackingSystem.WebApi/Features/Issue/IssueController.cs new file mode 100644 index 0000000000000000000000000000000000000000..7fcef120e3bb61e18be0e8d1e6a3eb7b77181894 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Issue/IssueController.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Issue; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Issue +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class IssueController : ControllerBase + { + private readonly IssueService _issueService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public IssueController(IssueService issueService, PermissionAuthorizationService permissionAuthorizationService) + { + _issueService = issueService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + public async Task GetIssues( + [FromQuery] string? search, + [FromQuery] long? taskId, + [FromQuery] long? projectId, + [FromQuery] AppTaskStatus? statusId, + [FromQuery] TaskPriority? priorityId, + [FromQuery] bool assignedOnly = false, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var issues = await _issueService.GetAllIssuesAsync(User.GetRoleId(), User.GetUserId()); + return Ok(issues); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var paged = await _issueService.GetPagedIssuesAsync( + User.GetRoleId(), + User.GetUserId(), + search, + taskId, + projectId, + statusId, + priorityId, + assignedOnly, + page, + limit); + + return Ok(paged); + } + + [HttpGet("{id}")] + public async Task> GetIssue(long id) + { + var issue = await _issueService.GetIssueByIdAsync(id, User.GetRoleId(), User.GetUserId()); + if (issue == null) + { + return NotFound(new { message = $"Issue with ID {id} not found." }); + } + + return Ok(issue); + } + + [HttpGet("task/{taskId}")] + public async Task>> GetIssuesByTask(long taskId) + { + var issues = await _issueService.GetIssuesByTaskIdAsync(taskId, User.GetRoleId(), User.GetUserId()); + return Ok(issues); + } + + [HttpPost] + public async Task>> CreateIssue([FromBody] CreateIssueDto dto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Issue", "Create")) + { + return StatusCode(403, Result.Failure("You do not have permission to create issues.", 403)); + } + + var result = await _issueService.CreateIssueAsync(dto, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpPut("{id}")] + public async Task> UpdateIssue(long id, [FromBody] UpdateIssueDto dto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Issue", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update issues.", 403)); + } + + var result = await _issueService.UpdateIssueAsync(id, dto, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("{id}")] + public async Task> DeleteIssue(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Issue", "Delete")) + { + return StatusCode(403, Result.Failure("You do not have permission to delete issues.", 403)); + } + + var result = await _issueService.SoftDeleteIssueAsync(id, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs b/TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs new file mode 100644 index 0000000000000000000000000000000000000000..716cb348890f6765300bfccd966dde0cec6b27ba --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs @@ -0,0 +1,338 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Issue; +using TaskTrackingSystem.WebApi.Infrastructure; +using IssueEntity = TaskTrackingSystem.Database.AppDbContextModels.Issue; + +namespace TaskTrackingSystem.WebApi.Features.Issue +{ + public class IssueService + { + private readonly AppDbContext _db; + + public IssueService(AppDbContext db) + { + _db = db; + } + + private static DateTime ToUtcDate(DateTime value) + { + return value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + } + + public async Task> GetAllIssuesAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + return await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .OrderByDescending(i => i.CreatedAt ?? DateTime.UtcNow) + .Select(ToDtoProjection()) + .ToListAsync(); + } + + public async Task> GetPagedIssuesAsync( + long roleId, + long currentUserId, + string? search, + long? taskId, + long? projectId, + AppTaskStatus? statusId, + TaskPriority? priorityId, + bool assignedOnly, + int page, + int pageSize) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var query = BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId); + + if (assignedOnly) + { + query = query.Where(i => i.AssignedTo == currentUserId); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchTerm = search.Trim().ToLower(); + query = query.Where(i => + (i.Title != null && i.Title.ToLower().Contains(searchTerm)) || + (i.Description != null && i.Description.ToLower().Contains(searchTerm))); + } + + if (taskId.HasValue && taskId.Value > 0) + { + query = query.Where(i => i.TaskId == taskId.Value); + } + + if (projectId.HasValue && projectId.Value > 0) + { + query = query.Where(i => i.Task.ProjectId == projectId.Value); + } + + if (statusId.HasValue) + { + query = query.Where(i => i.StatusId == statusId.Value); + } + + if (priorityId.HasValue) + { + query = query.Where(i => i.PriorityId == priorityId.Value); + } + + return await query + .OrderByDescending(i => i.CreatedAt ?? DateTime.UtcNow) + .Select(ToDtoProjection()) + .ToPagedResultAsync(page, pageSize); + } + + + + public async Task GetIssueByIdAsync(long id, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var issue = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .FirstOrDefaultAsync(i => i.Id == id); + + if (issue == null) + { + return null; + } + + return await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .Where(i => i.Id == id) + .Select(ToDtoProjection()) + .FirstOrDefaultAsync(); + } + + public async Task> GetIssuesByTaskIdAsync(long taskId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await CanAccessTaskAsync(taskId, isAdmin, currentUserId)) + { + return Array.Empty(); + } + + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + return await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .Where(i => i.TaskId == taskId) + .OrderByDescending(i => i.CreatedAt ?? DateTime.UtcNow) + .Select(ToDtoProjection()) + .ToListAsync(); + } + + public async Task> CreateIssueAsync(CreateIssueDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return Result.Failure("Issue title is required.", 400); + } + + var task = await _db.Tasks + .Include(t => t.Project) + .FirstOrDefaultAsync(t => t.Id == dto.TaskId && t.IsDeleted != true && !t.IsArchived && t.Project.IsDeleted != true); + + if (task == null) + { + return Result.Failure($"Task with ID {dto.TaskId} not found.", 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await CanAccessTaskAsync(task.Id, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this task.", 403); + } + + if (dto.AssignedTo.HasValue && !await IsTaskMemberAsync(task.ProjectId, dto.AssignedTo.Value)) + { + return Result.Failure("Issue assignee must belong to the selected project.", 400); + } + + var issue = new IssueEntity + { + TaskId = dto.TaskId, + Title = dto.Title.Trim(), + Description = dto.Description, + AssignedTo = dto.AssignedTo, + EstimatedHours = dto.EstimatedHours, + ActualHours = dto.ActualHours, + DelayReason = dto.DelayReason, + IsBlocked = dto.IsBlocked, + BlockedBy = dto.BlockedBy, + EscalationLevel = dto.EscalationLevel, + StartDate = ToUtcDate(dto.StartDate), + DueDate = ToUtcDate(dto.DueDate), + StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId, + PriorityId = dto.PriorityId == 0 ? TaskPriority.Medium : dto.PriorityId, + CreatedAt = DateTime.UtcNow, + CreatedBy = currentUserId, + IsDeleted = false + }; + + _db.Issues.Add(issue); + await _db.SaveChangesAsync(); + + var result = await GetIssueByIdAsync(issue.Id, roleId, currentUserId); + return Result.Success(result!, 201); + } + + public async Task UpdateIssueAsync(long id, UpdateIssueDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return Result.Failure("Issue title is required.", 400); + } + + var issue = await _db.Issues + .Include(i => i.Task) + .ThenInclude(t => t.Project) + .FirstOrDefaultAsync(i => i.Id == id && i.IsDeleted != true); + + if (issue == null) + { + return Result.Failure($"Issue with ID {id} not found.", 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await CanAccessTaskAsync(issue.TaskId, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this task.", 403); + } + + if (dto.AssignedTo.HasValue && !await IsTaskMemberAsync(issue.Task.ProjectId, dto.AssignedTo.Value)) + { + return Result.Failure("Issue assignee must belong to the selected project.", 400); + } + + issue.Title = dto.Title.Trim(); + issue.Description = dto.Description; + issue.AssignedTo = dto.AssignedTo; + issue.EstimatedHours = dto.EstimatedHours; + issue.ActualHours = dto.ActualHours; + issue.DelayReason = dto.DelayReason; + issue.IsBlocked = dto.IsBlocked; + issue.BlockedBy = dto.BlockedBy; + issue.EscalationLevel = dto.EscalationLevel; + issue.StartDate = ToUtcDate(dto.StartDate); + issue.DueDate = ToUtcDate(dto.DueDate); + issue.StatusId = dto.StatusId; + issue.PriorityId = dto.PriorityId; + issue.UpdatedAt = DateTime.UtcNow; + issue.UpdatedBy = currentUserId; + + _db.Issues.Update(issue); + await _db.SaveChangesAsync(); + return Result.Success(200); + } + + public async Task SoftDeleteIssueAsync(long id, long roleId, long currentUserId) + { + var issue = await _db.Issues + .Include(i => i.Task) + .ThenInclude(t => t.Project) + .FirstOrDefaultAsync(i => i.Id == id && i.IsDeleted != true); + + if (issue == null) + { + return Result.Failure($"Issue with ID {id} not found.", 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await CanAccessTaskAsync(issue.TaskId, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this task.", 403); + } + + issue.IsDeleted = true; + issue.UpdatedAt = DateTime.UtcNow; + issue.UpdatedBy = currentUserId; + + _db.Issues.Update(issue); + await _db.SaveChangesAsync(); + return Result.Success(200); + } + + private IQueryable BuildAccessibleIssueQuery(bool isAdmin, bool isManager, long currentUserId) + { + var query = _db.Issues + .Where(i => i.IsDeleted != true && i.Task.IsDeleted != true && i.Task.Project.IsDeleted != true); + + if (isAdmin) + { + return query; + } + + if (isManager) + { + return query.Where(i => + i.AssignedTo == currentUserId || + i.CreatedBy == currentUserId || + i.Task.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + + return query.Where(i => + i.AssignedTo == currentUserId || + i.CreatedBy == currentUserId); + } + + private async Task CanAccessTaskAsync(long taskId, bool isAdmin, long currentUserId) + { + if (isAdmin) + { + return await _db.Tasks.AnyAsync(t => t.Id == taskId && t.IsDeleted != true && !t.IsArchived && t.Project.IsDeleted != true); + } + + return await _db.Tasks.AnyAsync(t => + t.Id == taskId && + t.IsDeleted != true && + !t.IsArchived && + t.Project.IsDeleted != true && + (t.AssignedTo == currentUserId || + t.CreatedBy == currentUserId || + t.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId))); + } + + private async Task IsTaskMemberAsync(long projectId, long userId) + { + return await _db.ProjectMembers.AnyAsync(pm => pm.ProjectId == projectId && pm.UserId == userId); + } + + private static System.Linq.Expressions.Expression> ToDtoProjection() + { + return i => new IssueDto + { + Id = i.Id, + TaskId = i.TaskId, + TaskTitle = i.Task.Title, + ProjectId = i.Task.ProjectId, + ProjectName = i.Task.Project.Name, + Title = i.Title, + Description = i.Description, + AssignedTo = i.AssignedTo, + AssignedToName = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstName + " " + i.AssignedToNavigation.LastName + : null, + EstimatedHours = i.EstimatedHours, + ActualHours = i.ActualHours, + DelayReason = i.DelayReason, + IsBlocked = i.IsBlocked, + BlockedBy = i.BlockedBy, + EscalationLevel = i.EscalationLevel, + StartDate = i.StartDate, + DueDate = i.DueDate, + StatusId = i.StatusId, + PriorityId = i.PriorityId, + CreatedAt = i.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = i.UpdatedAt + }; + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Menu/MenuController.cs b/TaskTrackingSystem.WebApi/Features/Menu/MenuController.cs new file mode 100644 index 0000000000000000000000000000000000000000..00df521a9ea8a0cb07a3ce561bbd13bbcf9df812 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Menu/MenuController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using TaskTrackingSystem.Shared.Models.Menu; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Menu +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class MenuController : ControllerBase + { + private readonly MenuService _menuService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public MenuController(MenuService menuService, PermissionAuthorizationService permissionAuthorizationService) + { + _menuService = menuService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [HttpGet("tree")] + public async Task>> GetMenuTree() + { + var roleId = User.GetRoleId(); + if (roleId > 0) + { + var menuTree = await _menuService.GetMenuTreeByRoleIdAsync(roleId); + return Ok(menuTree); + } + + var roleName = User.GetRoleName(); + if (string.IsNullOrWhiteSpace(roleName)) + { + return Forbid(); + } + + var menuTreeByName = await _menuService.GetMenuTreeByRoleNameAsync(roleName); + return Ok(menuTreeByName); + } + + [HttpGet("all")] + [HttpGet("access-items")] + public async Task>> GetAllAccessItems() + { + var canCreate = await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Create"); + var canUpdate = await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Update"); + if (!canCreate && !canUpdate) + { + return Forbid(); + } + + var menus = await _menuService.GetAllAccessItemsAsync(); + return Ok(menus); + } + + [HttpGet("access-codes")] + public async Task>> GetCurrentAccessCodes() + { + var roleId = User.GetRoleId(); + var roleName = User.GetRoleName() ?? string.Empty; + var codes = await _menuService.GetCurrentAccessCodesAsync(roleId, roleName); + return Ok(codes); + } + + /// + /// Returns a lightweight version string for the current user's role permissions. + /// Clients poll this to detect when an admin has changed permissions without fetching the full menu list. + /// + [HttpGet("version")] + public async Task> GetMenuVersion() + { + var roleId = User.GetRoleId(); + var roleName = User.GetRoleName() ?? string.Empty; + var version = await _menuService.GetMenuVersionAsync(roleId, roleName); + return Ok(version); + } + } +} + + + diff --git a/TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs b/TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs new file mode 100644 index 0000000000000000000000000000000000000000..430d2281c0a2cbecea05cb89b3c78e7daba63d0d --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs @@ -0,0 +1,336 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared.Models.Menu; +using MenuEntity = TaskTrackingSystem.Database.AppDbContextModels.Menu; + +namespace TaskTrackingSystem.WebApi.Features.Menu +{ + public class MenuService + { + private readonly AppDbContext _db; + + public MenuService(AppDbContext db) + { + _db = db; + } + + public async Task> GetMenuTreeByRoleIdAsync(long roleId) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); + if (role == null) + { + return new List(); + } + + return await GetMenuTreeForRoleAsync(role.Id); + } + + public async Task> GetMenuTreeByRoleNameAsync(string roleName) + { + if (string.IsNullOrWhiteSpace(roleName)) + { + return new List(); + } + + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Name == roleName && r.IsDeleted != true); + if (role == null) + { + return new List(); + } + + return await GetMenuTreeForRoleAsync(role.Id); + } + + private async Task> GetMenuTreeForRoleAsync(long roleId) + { + var visibleMenus = await _db.Menus + .Where(m => !m.IsDeleted && m.Visible) + .OrderBy(m => m.OrderNo) + .ToListAsync(); + + var assignedMenuIds = await _db.RoleMenus + .Where(rm => rm.RoleId == roleId && !rm.IsDeleted) + .Select(rm => rm.MenuId) + .ToListAsync(); + + var assignedPermissionMenuIds = await _db.RolePermissions + .Where(rp => rp.RoleId == roleId && !rp.IsDeleted) + .Select(rp => rp.Permission.MenuId) + .ToListAsync(); + + var allowedMenuIds = assignedMenuIds + .Concat(assignedPermissionMenuIds) + .Distinct() + .ToHashSet(); + + if (allowedMenuIds.Count == 0) + { + return new List(); + } + + var visibleMenuLookup = visibleMenus.ToDictionary(m => m.MenuId); + var expandedMenuIds = ExpandWithAncestors(allowedMenuIds, visibleMenuLookup); + + var filteredMenus = visibleMenus + .Where(m => expandedMenuIds.Contains(m.MenuId)) + .OrderBy(m => m.OrderNo) + .ToList(); + + return BuildHierarchy(filteredMenus); + } + + private static List BuildHierarchy(List menus) + { + if (menus.Count == 0) + { + return new List(); + } + + var menuLookup = menus.ToDictionary(m => m.MenuId); + var dtoLookup = menus.ToDictionary( + m => m.MenuId, + m => new MenuDto + { + MenuCode = m.MenuCode, + ParentCode = GetParentCode(m, menuLookup), + MenuName = m.MenuName, + MenuUrl = m.MenuUrl, + OrderNo = m.OrderNo, + Icon = m.Icon + }); + + foreach (var dto in dtoLookup.Values) + { + dto.SubMenus = new List(); + } + + var roots = new List(); + + foreach (var menu in menus.OrderBy(m => m.OrderNo).ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase)) + { + var dto = dtoLookup[menu.MenuId]; + var parentId = menu.ParentMenuId; + + if (!parentId.HasValue || !dtoLookup.TryGetValue(parentId.Value, out var parentDto)) + { + roots.Add(dto); + continue; + } + + parentDto.SubMenus.Add(dto); + } + + SortMenuTree(roots); + return roots; + } + + private static string GetParentCode(MenuEntity menu, IReadOnlyDictionary menuLookup) + { + if (!menu.ParentMenuId.HasValue) + { + return string.Empty; + } + + return menuLookup.TryGetValue(menu.ParentMenuId.Value, out var parent) + ? parent.MenuCode + : string.Empty; + } + + private static HashSet ExpandWithAncestors(IEnumerable menuIds, IReadOnlyDictionary menuLookup) + { + var expanded = new HashSet(); + + foreach (var menuId in menuIds) + { + var currentId = menuId; + + while (currentId > 0 && expanded.Add(currentId)) + { + if (!menuLookup.TryGetValue(currentId, out var currentMenu) || !currentMenu.ParentMenuId.HasValue) + { + break; + } + + currentId = currentMenu.ParentMenuId.Value; + } + } + + return expanded; + } + + private static void SortMenuTree(IList menus) + { + var ordered = menus + .OrderBy(m => m.OrderNo) + .ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + menus.Clear(); + + foreach (var menu in ordered) + { + if (menu.SubMenus.Count > 0) + { + SortMenuTree(menu.SubMenus); + } + + menus.Add(menu); + } + } + + public async Task GetMenuVersionAsync(long roleId, string roleName) + { + if (roleId <= 0 && !string.IsNullOrWhiteSpace(roleName)) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Name == roleName && r.IsDeleted != true); + if (role != null) + { + roleId = role.Id; + } + } + + if (roleId <= 0) + { + return "0"; + } + + var latestRoleMenuChange = await _db.RoleMenus + .Where(rm => rm.RoleId == roleId && !rm.IsDeleted) + .Select(rm => (DateTime?)(rm.UpdatedAt ?? rm.CreatedAt)) + .MaxAsync(); + + var latestPermissionChange = await _db.RolePermissions + .Where(rp => rp.RoleId == roleId && !rp.IsDeleted) + .Select(rp => (DateTime?)(rp.UpdatedAt ?? rp.CreatedAt)) + .MaxAsync(); + + var latestMenuMetadataChange = await _db.Menus + .Select(m => (DateTime?)(m.UpdatedAt ?? m.CreatedAt)) + .MaxAsync(); + + var latest = latestRoleMenuChange ?? latestPermissionChange; + if (latestPermissionChange.HasValue && (!latest.HasValue || latestPermissionChange.Value > latest.Value)) + { + latest = latestPermissionChange; + } + if (latestMenuMetadataChange.HasValue && (!latest.HasValue || latestMenuMetadataChange.Value > latest.Value)) + { + latest = latestMenuMetadataChange; + } + + return latest.HasValue ? latest.Value.Ticks.ToString() : "0"; + } + + public async Task> GetAllAccessItemsAsync() + { + var menus = await _db.Menus + .Where(m => !m.IsDeleted && m.MenuCode != "ROLE_LAYOUTS" && m.MenuName != "Role Layouts") + .OrderBy(m => m.OrderNo) + .ToListAsync(); + + var permissions = await _db.Permissions + .Where(p => !p.IsDeleted) + .OrderBy(p => p.OrderNo) + .ToListAsync(); + + var menuLookup = menus.ToDictionary(m => m.MenuId); + var menuDtoLookup = menus + .Select(m => new + { + m.MenuId, + Dto = new AccessMenuDto + { + MenuCode = m.MenuCode, + ParentCode = GetParentCode(m, menuLookup), + MenuName = m.MenuName, + MenuUrl = m.MenuUrl, + OrderNo = m.OrderNo, + Icon = m.Icon, + Visible = m.Visible + } + }) + .ToList(); + + var menuDtos = menuDtoLookup.Select(x => x.Dto).ToList(); + var dtoLookup = menuDtoLookup.ToDictionary(x => x.MenuId, x => x.Dto); + + foreach (var permissionGroup in permissions.GroupBy(p => p.MenuId)) + { + if (!dtoLookup.TryGetValue(permissionGroup.Key, out var dto)) + { + continue; + } + + foreach (var permission in permissionGroup.OrderBy(p => p.OrderNo).ThenBy(p => p.ActionName, StringComparer.OrdinalIgnoreCase)) + { + dto.Permissions.Add(new AccessPermissionDto + { + PermissionId = permission.PermissionId.ToString(), + PermissionCode = permission.PermissionCode, + ParentMenuCode = menuLookup.TryGetValue(permission.MenuId, out var parentMenu) ? parentMenu.MenuCode : string.Empty, + ActionName = permission.ActionName, + ApiName = permission.ApiName, + Visible = permission.Visible, + OrderNo = permission.OrderNo + }); + } + } + + return menuDtos; + } + + public async Task> GetCurrentAccessCodesAsync(long roleId, string roleName) + { + if (roleId <= 0 && !string.IsNullOrWhiteSpace(roleName)) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Name == roleName && r.IsDeleted != true); + if (role != null) + { + roleId = role.Id; + } + } + + if (roleId <= 0) + { + return new List(); + } + + var validMenuCodes = await _db.Menus + .Where(m => !m.IsDeleted) + .Select(m => m.MenuCode) + .ToListAsync(); + + var validPermissionCodes = await _db.Permissions + .Where(p => !p.IsDeleted) + .Select(p => p.PermissionCode) + .ToListAsync(); + + var validCodes = validMenuCodes + .Concat(validPermissionCodes) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var menuCodes = await _db.RoleMenus + .Where(rm => rm.RoleId == roleId && !rm.IsDeleted) + .Select(rm => rm.Menu.MenuCode) + .ToListAsync(); + + var permissionCodes = await _db.RolePermissions + .Where(rp => rp.RoleId == roleId && !rp.IsDeleted) + .Select(rp => rp.Permission.PermissionCode) + .ToListAsync(); + + return menuCodes + .Concat(permissionCodes) + .Where(code => validCodes.Contains(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} + + + diff --git a/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs b/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9946c7442239726f88312698871c371f0f9673d4 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs @@ -0,0 +1,474 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared.Models.Notification; +using TaskTrackingSystem.Shared.Enums; +using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class FirebaseNotificationService +{ + private static readonly Regex MentionRegex = new(@"@(?[A-Za-z0-9._]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly SemaphoreSlim AccessTokenLock = new(1, 1); + private static string? CachedAccessToken; + private static DateTimeOffset CachedAccessTokenExpiresAt; + + private readonly AppDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly NotificationRealtimeService _realtimeService; + private readonly string _serviceAccountPath; + private readonly bool _pushEnabled; + + public FirebaseNotificationService( + AppDbContext db, + IHttpClientFactory httpClientFactory, + NotificationRealtimeService realtimeService, + IHostEnvironment environment, + IConfiguration configuration) + { + _db = db; + _httpClientFactory = httpClientFactory; + _realtimeService = realtimeService; + + var configuredPath = configuration["Firebase:ServiceAccountPath"] ?? "Firebase/ttsfirebasekey.json"; + _serviceAccountPath = Path.IsPathRooted(configuredPath) + ? configuredPath + : Path.Combine(environment.ContentRootPath, configuredPath); + _pushEnabled = configuration.GetValue("Firebase:PushEnabled", false); + } + + public global::System.Threading.Tasks.Task NotifyTaskAssignedAsync(Database.AppDbContextModels.Task task, long senderId) + { + if (!task.AssignedTo.HasValue) + { + return global::System.Threading.Tasks.Task.CompletedTask; + } + + var title = "Task assigned"; + var body = $"You have been assigned a new task: {task.Title}"; + + return SendAsync( + recipientIds: new[] { task.AssignedTo.Value }, + senderId: senderId, + notificationType: NotificationType.TaskAssigned, + sourceType: "task", + sourceId: task.Id, + title: title, + body: body); + } + + public async global::System.Threading.Tasks.Task NotifyTaskStatusChangedAsync(Database.AppDbContextModels.Task task, long senderId, AppTaskStatus oldStatusId, AppTaskStatus newStatusId) + { + var recipientIds = BuildTaskAudience(task, senderId); + if (recipientIds.Count == 0) + { + return; + } + + var title = "Task status changed"; + var body = $"Task '{task.Title}' changed from {GetStatusLabel(oldStatusId)} to {GetStatusLabel(newStatusId)}"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.StatusChanged, + "task", + task.Id, + title, + body); + } + + public async global::System.Threading.Tasks.Task NotifyProjectAssignedAsync(Database.AppDbContextModels.Project project, IEnumerable recipientIds, long senderId) + { + var uniqueRecipientIds = recipientIds + .Where(id => id > 0 && id != senderId) + .Distinct() + .ToList(); + + if (uniqueRecipientIds.Count == 0) + { + return; + } + + var title = "Project assigned"; + var body = $"You have been assigned to project '{project.Name}'"; + + await SendAsync( + uniqueRecipientIds, + senderId, + NotificationType.ProjectUpdated, + "project", + project.Id, + title, + body); + } + + public async global::System.Threading.Tasks.Task NotifyCommentAddedAsync(Database.AppDbContextModels.Task task, long senderId, string actorName, string message) + { + var recipientIds = BuildTaskAudience(task, senderId); + var mentionedIds = await FindMentionedUserIdsAsync(task.ProjectId, senderId, message); + if (mentionedIds.Count > 0) + { + recipientIds = recipientIds + .Except(mentionedIds) + .ToList(); + } + + if (recipientIds.Count == 0) + { + return; + } + + var title = "New comment"; + var body = $"{actorName} commented on task '{task.Title}'"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.CommentAdded, + "task", + task.Id, + title, + body); + } + + public async global::System.Threading.Tasks.Task NotifyMentionedAsync(Database.AppDbContextModels.Task task, long senderId, string actorName, string message) + { + var recipientIds = await FindMentionedUserIdsAsync(task.ProjectId, senderId, message); + if (recipientIds.Count == 0) + { + return; + } + + var title = "You were mentioned"; + var body = $"{actorName} mentioned you in task '{task.Title}'"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.Mention, + "task", + task.Id, + title, + body); + } + + private List BuildTaskAudience(Database.AppDbContextModels.Task task, long senderId) + { + var audience = new HashSet(); + + if (task.AssignedTo.HasValue && task.AssignedTo.Value != senderId) + { + audience.Add(task.AssignedTo.Value); + } + + if (task.AssignedBy.HasValue && task.AssignedBy.Value != senderId) + { + audience.Add(task.AssignedBy.Value); + } + + if (task.CreatedBy.HasValue && task.CreatedBy.Value != senderId) + { + audience.Add(task.CreatedBy.Value); + } + + return audience.ToList(); + } + + private async global::System.Threading.Tasks.Task> FindMentionedUserIdsAsync(long projectId, long senderId, string message) + { + var usernames = MentionRegex.Matches(message) + .Select(match => match.Groups["username"].Value) + .Where(username => !string.IsNullOrWhiteSpace(username)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (usernames.Count == 0) + { + return new List(); + } + + var projectMemberIds = await _db.ProjectMembers + .Where(pm => pm.ProjectId == projectId) + .Select(pm => pm.UserId) + .ToListAsync(); + + if (projectMemberIds.Count == 0) + { + return new List(); + } + + return await _db.Users + .Where(u => u.Id != senderId && projectMemberIds.Contains(u.Id) && usernames.Contains(u.Username)) + .Select(u => u.Id) + .Distinct() + .ToListAsync(); + } + + private async global::System.Threading.Tasks.Task SendAsync( + IEnumerable recipientIds, + long senderId, + NotificationType notificationType, + string sourceType, + long sourceId, + string title, + string body) + { + var uniqueRecipientIds = recipientIds.Distinct().ToList(); + if (uniqueRecipientIds.Count == 0) + { + return; + } + + var notifications = uniqueRecipientIds.Select(recipientId => new DbNotification + { + RecipientId = recipientId, + SenderId = senderId > 0 ? senderId : null, + NotificationType = (byte)notificationType, + Title = title, + Body = body, + SourceType = sourceType, + SourceId = sourceId, + IsRead = false, + CreatedAt = DateTime.UtcNow + }).ToList(); + + _db.Notifications.AddRange(notifications); + await _db.SaveChangesAsync(); + + var senderName = senderId > 0 + ? await _db.Users + .Where(u => u.Id == senderId) + .Select(u => $"{u.FirstName} {u.LastName}") + .FirstOrDefaultAsync() + : null; + + var unreadCounts = await _db.Notifications + .Where(n => uniqueRecipientIds.Contains(n.RecipientId) && !n.IsRead) + .GroupBy(n => n.RecipientId) + .Select(g => new { RecipientId = g.Key, Count = g.Count() }) + .ToListAsync(); + var unreadCountLookup = unreadCounts.ToDictionary(x => x.RecipientId, x => x.Count); + + foreach (var notification in notifications) + { + var dto = new NotificationDto + { + Id = notification.Id, + Title = notification.Title, + Body = notification.Body, + NotificationType = notification.NotificationType, + SourceType = notification.SourceType, + SourceId = notification.SourceId, + TargetUrl = NotificationNavigation.BuildTargetUrl(notification.SourceType, notification.SourceId, notification.NotificationType), + IsRead = notification.IsRead, + CreatedAt = notification.CreatedAt, + SenderName = senderName + }; + + var recipientUnreadCount = unreadCountLookup.TryGetValue(notification.RecipientId, out var unreadCount) + ? unreadCount + : 0; + + await _realtimeService.SendCreatedAsync(notification.RecipientId, dto, recipientUnreadCount); + } + + if (!_pushEnabled) + { + return; + } + + var tokens = await _db.UserDevices + .Where(d => uniqueRecipientIds.Contains(d.UserId)) + .Select(d => d.FcmToken) + .Distinct() + .ToListAsync(); + + foreach (var token in tokens) + { + try + { + await SendPushAsync(token, title, body, sourceType, sourceId, notificationType); + } + catch (Exception ex) + { + Console.WriteLine($"[Firebase] Push notification skipped: {ex.Message}"); + } + } + } + + private async global::System.Threading.Tasks.Task SendPushAsync( + string token, + string title, + string body, + string sourceType, + long sourceId, + NotificationType notificationType) + { + var serviceAccount = await LoadServiceAccountAsync(); + var accessToken = await GetAccessTokenAsync(serviceAccount); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"https://fcm.googleapis.com/v1/projects/{serviceAccount.ProjectId}/messages:send"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + + var payload = new + { + message = new + { + token, + notification = new + { + title, + body + }, + data = new Dictionary + { + ["sourceType"] = sourceType, + ["sourceId"] = sourceId.ToString(), + ["notificationType"] = ((byte)notificationType).ToString(), + ["targetUrl"] = NotificationNavigation.BuildTargetUrl(sourceType, sourceId, (byte)notificationType) + } + } + }; + + request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"[Firebase] Failed to send push: {(int)response.StatusCode} {response.ReasonPhrase}. {error}"); + } + } + + private async global::System.Threading.Tasks.Task GetAccessTokenAsync(FirebaseServiceAccount serviceAccount) + { + if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) + { + return CachedAccessToken!; + } + + await AccessTokenLock.WaitAsync(); + try + { + if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) + { + return CachedAccessToken!; + } + + var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var exp = iat + 3600; + + var headerJson = JsonSerializer.Serialize(new { alg = "RS256", typ = "JWT" }); + var claimJson = JsonSerializer.Serialize(new + { + iss = serviceAccount.ClientEmail, + scope = "https://www.googleapis.com/auth/firebase.messaging", + aud = "https://oauth2.googleapis.com/token", + iat, + exp + }); + + var unsignedJwt = $"{Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson))}.{Base64UrlEncode(Encoding.UTF8.GetBytes(claimJson))}"; + var signature = SignJwt(unsignedJwt, serviceAccount.PrivateKey); + var assertion = $"{unsignedJwt}.{Base64UrlEncode(signature)}"; + + var client = _httpClientFactory.CreateClient(); + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair("assertion", assertion) + }); + + using var response = await client.PostAsync("https://oauth2.googleapis.com/token", form); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var token = doc.RootElement.GetProperty("access_token").GetString(); + var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32(); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Firebase access token response did not contain an access_token."); + } + + CachedAccessToken = token; + CachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn); + + return token; + } + finally + { + AccessTokenLock.Release(); + } + } + + private async global::System.Threading.Tasks.Task LoadServiceAccountAsync() + { + var json = await File.ReadAllTextAsync(_serviceAccountPath); + var serviceAccount = JsonSerializer.Deserialize(json); + if (serviceAccount == null || + string.IsNullOrWhiteSpace(serviceAccount.ProjectId) || + string.IsNullOrWhiteSpace(serviceAccount.ClientEmail) || + string.IsNullOrWhiteSpace(serviceAccount.PrivateKey)) + { + throw new InvalidOperationException("Firebase service account file is missing required fields."); + } + + return serviceAccount; + } + + private static byte[] SignJwt(string unsignedJwt, string privateKey) + { + var pem = privateKey + .Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal) + .Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + + var keyBytes = Convert.FromBase64String(pem); + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(keyBytes, out _); + + return rsa.SignData(Encoding.ASCII.GetBytes(unsignedJwt), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => $"Status {statusId}" + }; + + private sealed class FirebaseServiceAccount + { + [JsonPropertyName("project_id")] + public string ProjectId { get; set; } = string.Empty; + + [JsonPropertyName("client_email")] + public string ClientEmail { get; set; } = string.Empty; + + [JsonPropertyName("private_key")] + public string PrivateKey { get; set; } = string.Empty; + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationCleanupHostedService.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationCleanupHostedService.cs new file mode 100644 index 0000000000000000000000000000000000000000..ecfe5c6fa673726796bdb67d9bbeb21484a2a146 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationCleanupHostedService.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TaskTrackingSystem.Database.AppDbContextModels; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class NotificationCleanupHostedService : BackgroundService +{ + private static readonly TimeSpan RunInterval = TimeSpan.FromHours(24); + private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(30); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public NotificationCleanupHostedService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async System.Threading.Tasks.Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(RunInterval); + + await CleanupOnceAsync(stoppingToken); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await CleanupOnceAsync(stoppingToken); + } + } + + private async System.Threading.Tasks.Task CleanupOnceAsync(CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cutoffUtc = DateTime.UtcNow.Subtract(RetentionWindow); + + var deletedCount = await db.Notifications + .Where(n => n.IsRead && (!n.CreatedAt.HasValue || n.CreatedAt < cutoffUtc)) + .ExecuteDeleteAsync(cancellationToken); + + if (deletedCount > 0) + { + _logger.LogInformation("Notification cleanup removed {DeletedCount} read notifications older than {CutoffUtc}.", deletedCount, cutoffUtc); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Notification cleanup failed."); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs new file mode 100644 index 0000000000000000000000000000000000000000..74861119f5faefc2173d7644529d5bcd8971ecde --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class NotificationController : ControllerBase +{ + private readonly NotificationService _notificationService; + + public NotificationController(NotificationService notificationService) + { + _notificationService = notificationService; + } + + [HttpGet("mine")] + public async Task>> GetMine([FromQuery] int take = 10) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(); + } + + var items = await _notificationService.GetRecentAsync(userId, take); + return Ok(items); + } + + [HttpGet("unread-count")] + public async Task> GetUnreadCount() + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(); + } + + var count = await _notificationService.GetUnreadCountAsync(userId); + return Ok(count); + } + + [HttpPost("{id}/read")] + public async Task> MarkRead(long id) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var result = await _notificationService.MarkReadAsync(userId, id); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("read")] + public async Task>> DeleteRead([FromQuery] DateTime? before = null) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var deletedCount = await _notificationService.DeleteReadNotificationsAsync(userId, before); + return Ok(Result.Success(deletedCount)); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationHub.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationHub.cs new file mode 100644 index 0000000000000000000000000000000000000000..082cf93c52527222c2e6a1b8b69bb302d823b52e --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationHub.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +[Authorize] +public class NotificationHub : Hub +{ + public static string GetUserGroupName(long userId) => $"notification-user-{userId}"; + + public override async global::System.Threading.Tasks.Task OnConnectedAsync() + { + var userId = Context.User?.GetUserId() ?? 0; + if (userId > 0) + { + await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroupName(userId)); + } + + await base.OnConnectedAsync(); + } + + public override async global::System.Threading.Tasks.Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.User?.GetUserId() ?? 0; + if (userId > 0) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetUserGroupName(userId)); + } + + await base.OnDisconnectedAsync(exception); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationRealtimeService.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationRealtimeService.cs new file mode 100644 index 0000000000000000000000000000000000000000..a9590df1c49d18c75e1495ac27c767991f461c31 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationRealtimeService.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.SignalR; +using TaskTrackingSystem.Shared.Models.Notification; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class NotificationRealtimeService +{ + private readonly IHubContext _hubContext; + + public NotificationRealtimeService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public async global::System.Threading.Tasks.Task SendCreatedAsync(long recipientId, NotificationDto notification, int unreadCount) + { + var group = _hubContext.Clients.Group(NotificationHub.GetUserGroupName(recipientId)); + await group.SendAsync("NotificationCreated", notification); + await group.SendAsync("UnreadCountChanged", unreadCount); + } + + public global::System.Threading.Tasks.Task SendUnreadCountAsync(long recipientId, int unreadCount) + { + return _hubContext.Clients + .Group(NotificationHub.GetUserGroupName(recipientId)) + .SendAsync("UnreadCountChanged", unreadCount); + } + + public global::System.Threading.Tasks.Task SendReadAsync(long recipientId, long notificationId, int unreadCount) + { + return _hubContext.Clients + .Group(NotificationHub.GetUserGroupName(recipientId)) + .SendAsync("NotificationRead", notificationId, unreadCount); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..59d51b1ee6011f4b1f42a2d4fffb1b4c4a6347a9 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Notification; +using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class NotificationService +{ + private readonly AppDbContext _db; + private readonly NotificationRealtimeService _realtimeService; + + public NotificationService(AppDbContext db, NotificationRealtimeService realtimeService) + { + _db = db; + _realtimeService = realtimeService; + } + + public async Task> GetRecentAsync(long userId, int take = 10) + { + var items = await ( + from n in _db.Notifications + where n.RecipientId == userId + join sender in _db.Users on n.SenderId equals sender.Id into senderGroup + from sender in senderGroup.DefaultIfEmpty() + orderby n.CreatedAt descending + select new NotificationDto + { + Id = n.Id, + Title = n.Title, + Body = n.Body, + NotificationType = n.NotificationType, + SourceType = n.SourceType, + SourceId = n.SourceId, + IsRead = n.IsRead, + CreatedAt = n.CreatedAt, + ReadAt = n.ReadAt, + SenderName = sender == null ? null : $"{sender.FirstName} {sender.LastName}" + }) + .Take(take) + .ToListAsync(); + + foreach (var item in items) + { + item.TargetUrl = NotificationNavigation.BuildTargetUrl(item.SourceType, item.SourceId, item.NotificationType); + } + + return items; + } + + public async Task GetUnreadCountAsync(long userId) + { + return await _db.Notifications.CountAsync(n => n.RecipientId == userId && !n.IsRead); + } + + public async Task MarkReadAsync(long userId, long notificationId) + { + var notification = await _db.Notifications + .FirstOrDefaultAsync(n => n.Id == notificationId && n.RecipientId == userId); + + if (notification == null) + { + return Result.Failure("Notification not found.", 404); + } + + if (!notification.IsRead) + { + notification.IsRead = true; + notification.ReadAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + + var unreadCount = await GetUnreadCountAsync(userId); + await _realtimeService.SendReadAsync(userId, notificationId, unreadCount); + } + + return Result.Success(); + } + + public async Task DeleteReadNotificationsAsync(long userId, DateTime? olderThanUtc = null) + { + var query = _db.Notifications.Where(n => n.RecipientId == userId && n.IsRead); + + if (olderThanUtc.HasValue) + { + var cutoff = olderThanUtc.Value; + query = query.Where(n => !n.CreatedAt.HasValue || n.CreatedAt < cutoff); + } + + return await query.ExecuteDeleteAsync(); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Project/ProjectController.cs b/TaskTrackingSystem.WebApi/Features/Project/ProjectController.cs new file mode 100644 index 0000000000000000000000000000000000000000..b096abaabb1f91e5b2f281103bf392c20295e97d --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Project/ProjectController.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.User; +using TaskTrackingSystem.Shared.Models.Task; +using TaskTrackingSystem.Shared.Models.Project; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Project +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class ProjectController : ControllerBase + { + private readonly ProjectService _projectService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public ProjectController(ProjectService projectService, PermissionAuthorizationService permissionAuthorizationService) + { + _projectService = projectService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetProjects( + [FromQuery] string? search, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var projects = await _projectService.GetAllProjectsAsync(User.GetRoleId(), User.GetUserId()); + return Ok(projects); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var paged = await _projectService.GetPagedProjectsAsync(User.GetRoleId(), User.GetUserId(), search, page, limit); + return Ok(paged); + } + + [HttpGet("{id}")] + public async Task> GetProject(long id) + { + var project = await _projectService.GetProjectByIdAsync(id, User.GetRoleId(), User.GetUserId()); + if (project == null) + { + return NotFound(new { message = $"Project with ID {id} not found." }); + } + return Ok(project); + } + + [HttpPost] + public async Task>> CreateProject([FromBody] CreateProjectDto createProjectDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Create")) + { + return StatusCode(403, Result.Failure("You do not have permission to create projects.", 403)); + } + + long? currentUserId = User.GetUserId(); + var result = await _projectService.CreateProjectAsync(createProjectDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpPut("{id}")] + public async Task> UpdateProject(long id, [FromBody] UpdateProjectDto updateProjectDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update projects.", 403)); + } + + var currentUserId = User.GetUserId(); + var result = await _projectService.UpdateProjectAsync(id, updateProjectDto, User.GetRoleId(), currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("{id}")] + public async Task> DeleteProject(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Delete")) + { + return StatusCode(403, Result.Failure("You do not have permission to delete projects.", 403)); + } + + var result = await _projectService.SoftDeleteProjectAsync(id, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("{id}/members")] + public async Task>>> GetProjectMembers(long id) + { + var result = await _projectService.GetProjectMembersAsync(id, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpPost("{id}/members")] + public async Task AssignProjectMembers(long id, [FromBody] AssignMembersDto dto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to assign project members.", 403)); + } + + var result = await _projectService.AssignMembersToProjectAsync(id, dto, User.GetRoleId(), User.GetUserId()); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); + } + return Ok(); + } + + [HttpDelete("{id}/members/{userId}")] + public async Task RemoveProjectMember(long id, long userId) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update project members.", 403)); + } + + var result = await _projectService.RemoveMemberFromProjectAsync(id, userId, User.GetRoleId(), User.GetUserId()); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); + } + return NoContent(); + } + + [HttpGet("{id}/tasks")] + public async Task>>> GetProjectTasks(long id) + { + var result = await _projectService.GetProjectTasksAsync(id, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs b/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs new file mode 100644 index 0000000000000000000000000000000000000000..2c45b20ef110bcecfda0d199f6a1275c63eeb197 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs @@ -0,0 +1,411 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Project; +using TaskTrackingSystem.Shared.Models.Task; +using TaskTrackingSystem.Shared.Models.User; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Project +{ + public class ProjectService + { + private readonly AppDbContext _db; + private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService; + private readonly Infrastructure.AuditLogService _auditLog; + + public ProjectService( + AppDbContext db, + TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService, + Infrastructure.AuditLogService auditLog) + { + _db = db; + _notificationService = notificationService; + _auditLog = auditLog; + } + + private static DateTime? GetCompletedAt(TaskTrackingSystem.Database.AppDbContextModels.Task task) + { + return task.StatusId == AppTaskStatus.Done + ? task.UpdatedAt ?? task.CreatedAt + : null; + } + + private static DateTime ToUtcDate(DateTime value) + { + return value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + } + + public async Task> GetAllProjectsAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + return await BuildAccessibleProjectQuery(isAdmin, currentUserId) + .OrderBy(p => p.EndDate) + .ThenByDescending(p => p.CreatedAt ?? DateTime.UtcNow) + .Select(p => new ProjectDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + StartDate = p.StartDate, + EndDate = p.EndDate, + CreatedById = p.CreatedById, + CreatedAt = p.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = p.BudgetedHours + }).ToListAsync(); + } + + public async Task> GetPagedProjectsAsync(long roleId, long currentUserId, string? search, int page, int pageSize) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var query = BuildAccessibleProjectQuery(isAdmin, currentUserId); + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchTerm = search.Trim().ToLower(); + query = query.Where(p => + (p.Name != null && p.Name.ToLower().Contains(searchTerm)) || + (p.Description != null && p.Description.ToLower().Contains(searchTerm))); + } + + return await query + .OrderBy(p => p.EndDate) + .ThenByDescending(p => p.CreatedAt ?? DateTime.UtcNow) + .Select(p => new ProjectDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + StartDate = p.StartDate, + EndDate = p.EndDate, + CreatedById = p.CreatedById, + CreatedAt = p.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = p.BudgetedHours + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task GetProjectByIdAsync(long id, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var project = await BuildAccessibleProjectQuery(isAdmin, currentUserId) + .FirstOrDefaultAsync(p => p.Id == id); + + if (project == null) + { + return null; + } + + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + StartDate = project.StartDate, + EndDate = project.EndDate, + CreatedById = project.CreatedById, + CreatedAt = project.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = project.BudgetedHours + }; + } + + public async Task> CreateProjectAsync(CreateProjectDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return Result.Failure(ResultMessages.ProjectNameRequired, 400); + } + + var project = new TaskTrackingSystem.Database.AppDbContextModels.Project + { + Name = dto.Name, + Description = dto.Description, + StartDate = ToUtcDate(dto.StartDate), + EndDate = ToUtcDate(dto.EndDate), + CreatedById = dto.CreatedById, + IsDeleted = false, + CreatedAt = DateTime.UtcNow, + CreatedBy = currentUserId, + BudgetedHours = dto.BudgetedHours + }; + + _db.Projects.Add(project); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Create", "Project", $"Created project '{project.Name}'"); + + var resultDto = new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + StartDate = project.StartDate, + EndDate = project.EndDate, + CreatedById = project.CreatedById, + CreatedAt = project.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = project.BudgetedHours + }; + + return Result.Success(resultDto, 201); + } + + public async Task UpdateProjectAsync(long id, UpdateProjectDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return Result.Failure(ResultMessages.ProjectNameRequired, 400); + } + + var project = await _db.Projects.FirstOrDefaultAsync(p => p.Id == id && p.IsDeleted != true); + if (project == null) + { + return Result.Failure(ResultMessages.ProjectNotFound(id), 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(id, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + + project.Name = dto.Name; + project.Description = dto.Description; + project.StartDate = ToUtcDate(dto.StartDate); + project.EndDate = ToUtcDate(dto.EndDate); + project.BudgetedHours = dto.BudgetedHours; + project.UpdatedAt = DateTime.UtcNow; + project.UpdatedBy = currentUserId; + + _db.Projects.Update(project); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Update", "Project", $"Updated project '{project.Name}'"); + return Result.Success(200); + } + + public async Task SoftDeleteProjectAsync(long id, long roleId, long currentUserId) + { + var project = await _db.Projects.FirstOrDefaultAsync(p => p.Id == id && p.IsDeleted != true); + if (project == null) + { + return Result.Failure(ResultMessages.ProjectNotFound(id), 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(id, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + + project.IsDeleted = true; + _db.Projects.Update(project); + + var tasksToUpdate = await _db.Tasks + .Where(t => t.ProjectId == id && t.IsDeleted != true) + .ToListAsync(); + foreach (var task in tasksToUpdate) + { + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + } + + if (tasksToUpdate.Any()) + { + _db.Tasks.UpdateRange(tasksToUpdate); + } + + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Delete", "Project", $"Deleted project '{project.Name}'"); + return Result.Success(200); + } + + public async Task>> GetProjectMembersAsync(long projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result>.Failure("You do not have access to this project.", 403); + } + + var members = await _db.ProjectMembers + .Where(pm => pm.ProjectId == projectId) + .Join(_db.Users.Where(u => !u.IsDeleted), + pm => pm.UserId, + u => u.Id, + (pm, u) => new UserDto + { + Id = u.Id, + Username = u.Username, + FirstName = u.FirstName, + LastName = u.LastName, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }) + .ToListAsync(); + + return Result>.Success(members); + } + + public async Task AssignMembersToProjectAsync(long projectId, AssignMembersDto dto, long roleId, long currentUserId) + { + var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true); + if (!projectExists) + { + return Result.Failure(ResultMessages.ProjectNotFound(projectId), 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + + if (dto.UserIds == null) + { + return Result.Failure(ResultMessages.UserIdsCannotBeNull, 400); + } + + if (dto.UserIds.Any()) + { + var validUserIds = await _db.Users + .Where(u => dto.UserIds.Contains(u.Id) && !u.IsDeleted) + .Select(u => u.Id) + .ToListAsync(); + + var invalidUserIds = dto.UserIds.Except(validUserIds).ToList(); + if (invalidUserIds.Any()) + { + return Result.Failure(ResultMessages.InvalidUserIds(string.Join(", ", invalidUserIds)), 400); + } + } + + var existingMembers = await _db.ProjectMembers + .Where(pm => pm.ProjectId == projectId) + .ToListAsync(); + var previousMemberIds = existingMembers.Select(pm => pm.UserId).ToHashSet(); + + if (existingMembers.Any()) + { + _db.ProjectMembers.RemoveRange(existingMembers); + } + + foreach (var userId in dto.UserIds) + { + _db.ProjectMembers.Add(new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = DateTime.UtcNow + }); + } + + await _db.SaveChangesAsync(); + + var addedMemberIds = dto.UserIds.Except(previousMemberIds).ToList(); + if (addedMemberIds.Count > 0) + { + var project = await _db.Projects.FirstOrDefaultAsync(p => p.Id == projectId && p.IsDeleted != true); + if (project != null) + { + await _notificationService.NotifyProjectAssignedAsync(project, addedMemberIds, currentUserId); + } + } + + return Result.Success(200); + } + + public async Task RemoveMemberFromProjectAsync(long projectId, long userId, long roleId, long currentUserId) + { + var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true); + if (!projectExists) + { + return Result.Failure(ResultMessages.ProjectNotFound(projectId), 404); + } + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + + var member = await _db.ProjectMembers + .FirstOrDefaultAsync(pm => pm.ProjectId == projectId && pm.UserId == userId); + + if (member == null) + { + return Result.Failure(ResultMessages.UserNotProjectMember(userId, projectId), 404); + } + + _db.ProjectMembers.Remove(member); + await _db.SaveChangesAsync(); + + return Result.Success(204); + } + + public async Task>> GetProjectTasksAsync(long projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result>.Failure("You do not have access to this project.", 403); + } + + var tasks = await _db.Tasks + .Where(t => t.ProjectId == projectId && t.IsDeleted != true && !t.IsArchived) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + StatusId = t.StatusId, + PriorityId = t.PriorityId, + AssignedTo = t.AssignedTo, + AssignedBy = t.AssignedBy, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + IsArchived = t.IsArchived, + CompletedAt = GetCompletedAt(t) + }) + .ToListAsync(); + + return Result>.Success(tasks); + } + + private IQueryable BuildAccessibleProjectQuery(bool isAdmin, long currentUserId) + { + var query = _db.Projects.Where(p => p.IsDeleted != true); + + if (isAdmin) + { + return query; + } + + return query.Where(p => + p.CreatedById == currentUserId || + p.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + + private async Task IsProjectAccessibleAsync(long projectId, bool isAdmin, long currentUserId) + { + if (isAdmin) + { + return await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true); + } + + return await _db.Projects.AnyAsync(p => + p.Id == projectId && + p.IsDeleted != true && + (p.CreatedById == currentUserId || p.ProjectMembers.Any(pm => pm.UserId == currentUserId))); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs b/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs new file mode 100644 index 0000000000000000000000000000000000000000..f7e9d51dcf6afacd01776fbfb768a04315760a0f --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Report; +using TaskTrackingSystem.Shared.Models.Issue; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Report +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class ReportController : ControllerBase + { + private readonly ReportService _reportService; + + public ReportController(ReportService reportService) + { + _reportService = reportService; + } + + [HttpGet("issues")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>>> GetIssuesReport() + { + var result = await _reportService.GetIssuesReportAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("tasks")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetTasksReport( + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] int? projectId, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var full = await _reportService.GetTasksReportAsync( + startDate, endDate, status, projectId, User.GetRoleId(), User.GetUserId(), assignedToMe, assignedToMyTeam); + return StatusCode(full.StatusCode, full); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var result = await _reportService.GetPagedTasksReportAsync( + startDate, endDate, status, projectId, User.GetRoleId(), User.GetUserId(), page, limit, assignedToMe, assignedToMyTeam); + return Ok(result); + } + + [HttpGet("user-productivity")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetUserProductivityReport( + [FromQuery] PaginationQuery? paging = null) + { + var users = await _reportService.GetUserProductivityReportAsync(User.GetRoleId(), User.GetUserId()); + if (!users.IsSuccess || users.Value == null) + { + return StatusCode(users.StatusCode, new PagedResult()); + } + + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + return StatusCode(users.StatusCode, users); + } + + var page = PaginationExtensions.NormalizePage(paging?.Page); + var limit = PaginationExtensions.NormalizePageSize(paging?.Limit ?? 0); + var query = users.Value.AsQueryable(); + var paged = await query.ToPagedResultAsync(page, limit); + return Ok(paged); + } + + [HttpGet("task-status-summary")] + public async Task>>> GetTaskStatusSummary( + [FromQuery] string? search, + [FromQuery] AppTaskStatus? statusId, + [FromQuery] long? projectId) + { + var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("task-status-summary/excel")] + public async Task DownloadTaskStatusSummaryExcel( + [FromQuery] string? search, + [FromQuery] AppTaskStatus? statusId, + [FromQuery] long? projectId) + { + var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleId(), User.GetUserId()); + if (!result.IsSuccess || result.Value == null) + return BadRequest(new { message = result.ErrorMessage }); + var bytes = _reportService.ExportTaskStatusSummaryToExcel(result.Value); + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"TaskStatusSummary_{DateTime.Today:yyyyMMdd}.xlsx"); + } + + [HttpGet("team-productivity")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetTeamProductivity( + [FromQuery] string? search, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var full = await _reportService.GetTeamProductivityAsync(search, User.GetRoleId(), User.GetUserId()); + return StatusCode(full.StatusCode, full); + } + + var page = PaginationExtensions.NormalizePage(paging?.Page); + var limit = PaginationExtensions.NormalizePageSize(paging?.Limit ?? 0); + var result = await _reportService.GetPagedTeamProductivityAsync(search, User.GetRoleId(), User.GetUserId(), page, limit); + return Ok(result); + } + + [HttpGet("team-productivity/excel")] + public async Task DownloadTeamProductivityExcel([FromQuery] string? search) + { + var result = await _reportService.GetTeamProductivityAsync(search, User.GetRoleId(), User.GetUserId()); + if (!result.IsSuccess || result.Value == null) + return BadRequest(new { message = result.ErrorMessage }); + var bytes = _reportService.ExportTeamProductivityToExcel(result.Value); + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"TeamProductivity_{DateTime.Today:yyyyMMdd}.xlsx"); + } + + [HttpGet("overdue-critical")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetOverdueCritical( + [FromQuery] string? search, + [FromQuery] long? projectId, + [FromQuery] int? priorityId, + [FromQuery] string? delayType, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var full = await _reportService.GetOverdueCriticalTasksAsync( + search, projectId, User.GetRoleId(), User.GetUserId(), priorityId, delayType, assignedToMe, assignedToMyTeam); + return StatusCode(full.StatusCode, full); + } + + var page = PaginationExtensions.NormalizePage(paging?.Page); + var limit = PaginationExtensions.NormalizePageSize(paging?.Limit ?? 0); + var result = await _reportService.GetPagedOverdueCriticalTasksAsync( + search, projectId, User.GetRoleId(), User.GetUserId(), page, limit, priorityId, delayType, assignedToMe, assignedToMyTeam); + return Ok(result); + } + + [HttpGet("overdue-critical/excel")] + public async Task DownloadOverdueCriticalExcel( + [FromQuery] string? search, + [FromQuery] long? projectId, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam) + { + var result = await _reportService.GetOverdueCriticalTasksAsync( + search, projectId, User.GetRoleId(), User.GetUserId(), assignedToMe: assignedToMe, assignedToMyTeam: assignedToMyTeam); + if (!result.IsSuccess || result.Value == null) + return BadRequest(new { message = result.ErrorMessage }); + var bytes = _reportService.ExportOverdueCriticalToExcel(result.Value); + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"OverdueCriticalTasks_{DateTime.Today:yyyyMMdd}.xlsx"); + } + + [HttpGet("employee-productivity")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetEmployeeProductivity( + [FromQuery] string? search, + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var reportData = await _reportService.GetEmployeeProductivityReportAsync( + search, startDate, endDate, status, User.GetRoleId(), User.GetUserId(), assignedToMe, assignedToMyTeam); + return StatusCode(reportData.StatusCode, reportData); + } + + var reportDataPaged = await _reportService.GetPagedEmployeeProductivityReportAsync( + search, startDate, endDate, status, User.GetRoleId(), User.GetUserId(), + paging.Page ?? 1, paging.Limit ?? 10, assignedToMe, assignedToMyTeam); + return Ok(reportDataPaged); + } + + [HttpGet("project-progress")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetProjectProgress( + [FromQuery] string? search, + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var reportData = await _reportService.GetProjectProgressReportAsync( + search, startDate, endDate, status, User.GetRoleId(), User.GetUserId(), assignedToMe, assignedToMyTeam); + return StatusCode(reportData.StatusCode, reportData); + } + + var reportDataPaged = await _reportService.GetPagedProjectProgressReportAsync( + search, startDate, endDate, status, User.GetRoleId(), User.GetUserId(), + paging.Page ?? 1, paging.Limit ?? 10, assignedToMe, assignedToMyTeam); + return Ok(reportDataPaged); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs new file mode 100644 index 0000000000000000000000000000000000000000..80c20a9e4d7c72ca4055080e7ea160e11941de50 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs @@ -0,0 +1,1068 @@ +using ClosedXML.Excel; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Report; +using TaskTrackingSystem.Shared.Models.Issue; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Report +{ + public class ReportService + { + private readonly AppDbContext _db; + private static readonly Dictionary StatusMap = new() { { AppTaskStatus.Todo, "To Do" }, { AppTaskStatus.InProgress, "In Progress" }, { AppTaskStatus.Done, "Done" } }; + private static readonly Dictionary PriorityMap = new() { { TaskPriority.Low, "Low" }, { TaskPriority.Medium, "Medium" }, { TaskPriority.High, "High" } }; + + public ReportService(AppDbContext db) + { + _db = db; + } + + public async Task>> GetIssuesReportAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var list = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .Include(i => i.Task) + .ThenInclude(t => t.Project) + .Include(i => i.AssignedToNavigation) + .OrderBy(i => i.DueDate) + .ThenBy(i => i.Title) + .Select(i => new IssueDto + { + Id = i.Id, + TaskId = i.TaskId, + TaskTitle = i.Task.Title, + ProjectId = i.Task.ProjectId, + ProjectName = i.Task.Project.Name, + Title = i.Title, + Description = i.Description, + AssignedTo = i.AssignedTo, + AssignedToName = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstName + " " + i.AssignedToNavigation.LastName + : null, + EstimatedHours = i.EstimatedHours, + ActualHours = i.ActualHours, + DelayReason = i.DelayReason, + IsBlocked = i.IsBlocked, + BlockedBy = i.BlockedBy, + EscalationLevel = i.EscalationLevel, + StartDate = i.StartDate, + DueDate = i.DueDate, + StatusId = i.StatusId, + PriorityId = i.PriorityId, + CreatedAt = i.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = i.UpdatedAt + }) + .ToListAsync(); + + return Result>.Success(list); + } + + private IQueryable BuildAccessibleTaskQuery(bool isAdmin, bool isManager, long currentUserId) + { + return _db.Tasks.Where(t => t.IsDeleted != true && !t.IsArchived); + } + + private IQueryable BuildAccessibleProjectQuery(bool isAdmin, long currentUserId) + { + return _db.Projects.Where(p => p.IsDeleted != true); + } + + private IQueryable BuildAccessibleUserQuery(bool isAdmin, long currentUserId) + { + return _db.Users.Where(u => !u.IsDeleted); + } + + private IQueryable BuildAccessibleIssueQuery(bool isAdmin, bool isManager, long currentUserId) + { + var query = _db.Issues + .Where(i => i.IsDeleted != true && i.Task.IsDeleted != true && i.Task.Project.IsDeleted != true); + + if (isAdmin) + { + return query; + } + + if (isManager) + { + return query.Where(i => + i.AssignedTo == currentUserId || + i.CreatedBy == currentUserId || + i.Task.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + + return query.Where(i => + i.AssignedTo == currentUserId || + i.CreatedBy == currentUserId); + } + + private static PagedResult MapPagedResult( + PagedResult source, + Func map) + { + return new PagedResult + { + Items = source.Items.Select(map).ToList(), + TotalCount = source.TotalCount, + Page = source.Page, + PageSize = source.PageSize, + TotalPages = source.TotalPages + }; + } + + private async Task> GetTeamUserIdsAsync(long currentUserId) + { + var myProjectIds = await _db.ProjectMembers + .Where(pm => pm.UserId == currentUserId) + .Select(pm => pm.ProjectId) + .Distinct() + .ToListAsync(); + + return await _db.ProjectMembers + .Where(pm => myProjectIds.Contains(pm.ProjectId) && pm.UserId != currentUserId) + .Select(pm => pm.UserId) + .Distinct() + .ToListAsync(); + } + + private async Task> BuildTaskReportQueryAsync( + bool isAdmin, + bool isManager, + long currentUserId, + DateTime? startDate, + DateTime? endDate, + string? status, + int? projectId, + bool? assignedToMe, + bool? assignedToMyTeam) + { + var query = BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .Include(t => t.Project) + .Include(t => t.AssignedToNavigation) + .Include(t => t.AssignedByNavigation) + .Where(t => t.IsDeleted != true); + + if (startDate.HasValue) + query = query.Where(t => t.CreatedAt >= startDate.Value.Date); + + if (endDate.HasValue) + query = query.Where(t => t.CreatedAt < endDate.Value.Date.AddDays(1)); + + if (projectId.HasValue) + query = query.Where(t => t.ProjectId == projectId.Value); + + if (!string.IsNullOrWhiteSpace(status)) + { + var sl = status.Trim().ToLower(); + if (sl == "uncompleted") + { + query = query.Where(t => t.StatusId != AppTaskStatus.Done); + } + else if (sl == "overdue") + { + query = query.Where(t => t.DueDate < DateTime.Today && t.StatusId != AppTaskStatus.Done); + } + else + { + AppTaskStatus? sid = sl switch + { + "to do" => AppTaskStatus.Todo, + "in progress" => AppTaskStatus.InProgress, + "done" => AppTaskStatus.Done, + _ => null + }; + + if (sid.HasValue) + query = query.Where(t => t.StatusId == sid.Value); + else if (Enum.TryParse(status, true, out var parsedStatus)) + query = query.Where(t => t.StatusId == parsedStatus); + } + } + + if (assignedToMe == true || assignedToMyTeam == true) + { + var teamIds = assignedToMyTeam == true ? await GetTeamUserIdsAsync(currentUserId) : new List(); + query = query.Where(t => + (assignedToMe == true && t.AssignedTo == currentUserId) || + (assignedToMyTeam == true && t.AssignedTo.HasValue && teamIds.Contains(t.AssignedTo.Value)) + ); + } + + return query; + } + + private static TaskReportDto MapTaskReport(TaskTrackingSystem.Database.AppDbContextModels.Task t) + { + return new TaskReportDto + { + TaskId = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + ProjectName = t.Project?.Name ?? string.Empty, + StatusId = t.StatusId, + StatusName = StatusMap.TryGetValue(t.StatusId, out var s) ? s : $"Status {t.StatusId}", + PriorityId = t.PriorityId, + PriorityName = PriorityMap.TryGetValue(t.PriorityId, out var p) ? p : $"Priority {t.PriorityId}", + AssignedToUserId = t.AssignedTo, + AssignedToUser = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null, + AssignedByUser = t.AssignedByNavigation != null ? $"{t.AssignedByNavigation.FirstName} {t.AssignedByNavigation.LastName}" : null, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + LastActivity = t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow + }; + } + + private async System.Threading.Tasks.Task EnrichTaskReportsAsync(List reports) + { + if (reports.Count == 0) + { + return; + } + + var today = DateTime.Today; + var taskIds = reports.Select(r => r.TaskId).ToList(); + var issues = await _db.Issues + .Where(i => i.IsDeleted != true && taskIds.Contains(i.TaskId)) + .Select(i => new + { + i.TaskId, + i.Title, + i.StatusId, + i.PriorityId, + i.DueDate, + i.UpdatedAt, + i.CreatedAt, + i.IsBlocked + }) + .ToListAsync(); + + foreach (var report in reports) + { + var taskIssues = issues.Where(i => i.TaskId == report.TaskId).ToList(); + report.OpenIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done); + report.OverdueIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today); + report.IssueSummary = taskIssues.Count == 0 + ? "No issues" + : $"{report.OpenIssues} open, {report.OverdueIssues} overdue, {taskIssues.Count(i => i.IsBlocked)} blocked"; + report.LastActivity = taskIssues + .Select(i => i.UpdatedAt ?? i.CreatedAt ?? report.LastActivity) + .DefaultIfEmpty(report.LastActivity) + .Max(); + + report.TaskHealth = report.StatusId == AppTaskStatus.Done + ? "Healthy" + : report.OverdueIssues > 0 || report.DueDate.Date < today || taskIssues.Any(i => i.IsBlocked) + ? "Critical" + : report.PriorityId == TaskPriority.High || report.DueDate.Date <= today.AddDays(3) + ? "At Risk" + : "Healthy"; + } + } + + private async System.Threading.Tasks.Task EnrichOverdueTaskReportsAsync(List reports) + { + if (reports.Count == 0) + { + return; + } + + var today = DateTime.Today; + var taskIds = reports.Select(r => r.TaskId).ToList(); + var issues = await _db.Issues + .Where(i => i.IsDeleted != true && taskIds.Contains(i.TaskId)) + .Select(i => new + { + i.TaskId, + i.StatusId, + i.DueDate, + i.DelayReason, + i.IsBlocked, + i.BlockedBy, + i.EscalationLevel + }) + .ToListAsync(); + + foreach (var report in reports) + { + var taskIssues = issues.Where(i => i.TaskId == report.TaskId).ToList(); + report.OverdueIssues = taskIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today); + var riskIssue = taskIssues + .OrderByDescending(i => i.IsBlocked) + .ThenByDescending(i => i.EscalationLevel) + .ThenBy(i => i.DueDate) + .FirstOrDefault(); + report.DelayReason = riskIssue?.DelayReason ?? (report.DaysOverdue > 0 ? "Past due date" : "High priority due soon"); + report.BlockedBy = riskIssue?.BlockedBy ?? string.Empty; + report.EscalationLevel = riskIssue?.EscalationLevel ?? 0; + } + } + + // ——— Legacy endpoints (kept for backward compatibility) ——— + + public async Task>> GetTasksReportAsync( + DateTime? startDate, DateTime? endDate, string? status, int? projectId, long roleId, long currentUserId, + bool? assignedToMe = null, bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var query = await BuildTaskReportQueryAsync(isAdmin, isManager, currentUserId, startDate, endDate, status, projectId, assignedToMe, assignedToMyTeam); + var tasks = await query + .OrderBy(t => t.DueDate) + .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow) + .ToListAsync(); + var list = tasks.Select(MapTaskReport).ToList(); + await EnrichTaskReportsAsync(list); + return Result>.Success(list); + } + + public async Task> GetPagedTasksReportAsync( + DateTime? startDate, + DateTime? endDate, + string? status, + int? projectId, + long roleId, + long currentUserId, + int page, + int pageSize, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var query = await BuildTaskReportQueryAsync(isAdmin, isManager, currentUserId, startDate, endDate, status, projectId, assignedToMe, assignedToMyTeam); + var paged = await query + .OrderBy(t => t.DueDate) + .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow) + .ToPagedResultAsync(page, pageSize); + + var mapped = MapPagedResult(paged, MapTaskReport); + await EnrichTaskReportsAsync(mapped.Items); + return mapped; + } + + public async Task>> GetUserProductivityReportAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var users = await BuildAccessibleUserQuery(isAdmin, currentUserId).ToListAsync(); + var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync(); + var list = new List(); + foreach (var user in users) + { + var userTasks = tasks.Where(t => t.AssignedTo == user.Id).ToList(); + int total = userTasks.Count; + var completedTasks = userTasks.Where(t => t.StatusId == AppTaskStatus.Done).ToList(); + int done = completedTasks.Count; + + int onTimeCount = completedTasks.Count(t => (t.UpdatedAt ?? t.CreatedAt) <= t.DueDate); + double onTimeDeliveryRate = done > 0 ? Math.Round(((double)onTimeCount / done) * 100, 2) : 0; + + list.Add(new UserProductivityDto + { + UserId = user.Id, + Username = user.Username, + FullName = $"{user.FirstName} {user.LastName}".Trim(), + TotalAssignedTasks = total, + CompletedTasksCount = done, + EfficiencyRatio = total > 0 ? Math.Round(((double)done / total) * 100, 2) : 0, + OnTimeDeliveryRate = onTimeDeliveryRate + }); + } + return Result>.Success(list); + } + + public async Task>> GetTaskStatusSummaryAsync( + string? search, AppTaskStatus? statusId, long? projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var query = BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .Include(t => t.Project) + .Include(t => t.AssignedToNavigation) + .Where(t => t.IsDeleted != true); + + if (statusId.HasValue && Convert.ToInt64(statusId.Value) > 0) + query = query.Where(t => t.StatusId == statusId.Value); + if (projectId.HasValue && projectId > 0) + query = query.Where(t => t.ProjectId == projectId.Value); + + var tasks = await query.OrderBy(t => t.DueDate).ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow).ToListAsync(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + tasks = tasks.Where(t => + t.Title.ToLower().Contains(s) || + (t.Project?.Name.ToLower().Contains(s) == true) || + (t.AssignedToNavigation != null && + ($"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}").ToLower().Contains(s)) + ).ToList(); + } + + var list = tasks.Select(t => new TaskStatusSummaryDto + { + TaskId = t.Id, + Title = t.Title, + ProjectName = t.Project?.Name ?? "-", + StatusName = StatusMap.TryGetValue(t.StatusId, out var s) ? s : $"Status {t.StatusId}", + PriorityName = PriorityMap.TryGetValue(t.PriorityId, out var p) ? p : $"Priority {t.PriorityId}", + AssignedTo = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow + }).ToList(); + + return Result>.Success(list); + } + + public byte[] ExportTaskStatusSummaryToExcel(IEnumerable data) + { + using var wb = new XLWorkbook(); + var ws = wb.Worksheets.Add("Task Status Summary"); + var headers = new[] { "Task ID", "Title", "Project", "Status", "Priority", "Assigned To", "Due Date", "Created At", "Overdue" }; + for (int i = 0; i < headers.Length; i++) + { + ws.Cell(1, i + 1).Value = headers[i]; + ws.Cell(1, i + 1).Style.Font.Bold = true; + ws.Cell(1, i + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#6D28D9"); + ws.Cell(1, i + 1).Style.Font.FontColor = XLColor.White; + } + int row = 2; + foreach (var item in data) + { + ws.Cell(row, 1).Value = item.TaskId; + ws.Cell(row, 2).Value = item.Title; + ws.Cell(row, 3).Value = item.ProjectName; + ws.Cell(row, 4).Value = item.StatusName; + ws.Cell(row, 5).Value = item.PriorityName; + ws.Cell(row, 6).Value = item.AssignedTo ?? "Unassigned"; + ws.Cell(row, 7).Value = DisplayFormats.Date(item.DueDate); + ws.Cell(row, 8).Value = DisplayFormats.Date(item.CreatedAt); + ws.Cell(row, 9).Value = item.IsOverdue ? "Yes" : "No"; + if (item.IsOverdue) ws.Row(row).Style.Fill.BackgroundColor = XLColor.FromHtml("#FEE2E2"); + row++; + } + ws.Columns().AdjustToContents(); + using var ms = new MemoryStream(); + wb.SaveAs(ms); + return ms.ToArray(); + } + + public async Task> GetPagedTeamProductivityAsync( + string? search, + long roleId, + long currentUserId, + int page, + int pageSize) + { + var full = await GetTeamProductivityAsync(search, roleId, currentUserId); + if (!full.IsSuccess || full.Value == null) + { + return new PagedResult(); + } + + var ordered = full.Value.OrderByDescending(u => u.CompletionRate).ToList(); + var normalizedPageSize = PaginationExtensions.NormalizePageSize(pageSize); + var totalCount = ordered.Count; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)normalizedPageSize); + var normalizedPage = Math.Max(page, 1); + if (totalPages > 0 && normalizedPage > totalPages) + normalizedPage = totalPages; + + var items = ordered + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToList(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + } + + // ——— Report 2: Team Productivity ——— + + public async Task>> GetTeamProductivityAsync(string? search, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var users = await BuildAccessibleUserQuery(isAdmin, currentUserId).ToListAsync(); + var allTasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .Where(t => t.AssignedTo != null) + .ToListAsync(); + var now = DateTime.UtcNow; + + var list = users.Select(u => + { + var uTasks = allTasks.Where(t => t.AssignedTo == u.Id).ToList(); + return new TeamProductivityReportDto + { + UserId = u.Id, + FullName = $"{u.FirstName} {u.LastName}".Trim(), + Username = u.Username, + TotalAssigned = uTasks.Count, + Completed = uTasks.Count(t => t.StatusId == AppTaskStatus.Done), + InProgress = uTasks.Count(t => t.StatusId == AppTaskStatus.InProgress), + ToDo = uTasks.Count(t => t.StatusId == AppTaskStatus.Todo), + Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId != AppTaskStatus.Done), + CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId == AppTaskStatus.Done) / uTasks.Count * 100, 1) : 0 + }; + }).ToList(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + list = list.Where(u => u.FullName.ToLower().Contains(s) || u.Username.ToLower().Contains(s)).ToList(); + } + + return Result>.Success(list.OrderByDescending(u => u.CompletionRate)); + } + + public byte[] ExportTeamProductivityToExcel(IEnumerable data) + { + using var wb = new XLWorkbook(); + var ws = wb.Worksheets.Add("Team Productivity"); + var headers = new[] { "User ID", "Full Name", "Username", "Total Assigned", "Completed", "In Progress", "To Do", "Overdue", "Completion Rate (%)" }; + for (int i = 0; i < headers.Length; i++) + { + ws.Cell(1, i + 1).Value = headers[i]; + ws.Cell(1, i + 1).Style.Font.Bold = true; + ws.Cell(1, i + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#059669"); + ws.Cell(1, i + 1).Style.Font.FontColor = XLColor.White; + } + int row = 2; + foreach (var item in data) + { + ws.Cell(row, 1).Value = item.UserId; + ws.Cell(row, 2).Value = item.FullName; + ws.Cell(row, 3).Value = item.Username; + ws.Cell(row, 4).Value = item.TotalAssigned; + ws.Cell(row, 5).Value = item.Completed; + ws.Cell(row, 6).Value = item.InProgress; + ws.Cell(row, 7).Value = item.ToDo; + ws.Cell(row, 8).Value = item.Overdue; + ws.Cell(row, 9).Value = item.CompletionRate; + if (item.Overdue > 0) ws.Row(row).Style.Fill.BackgroundColor = XLColor.FromHtml("#FEF3C7"); + row++; + } + ws.Columns().AdjustToContents(); + using var ms = new MemoryStream(); + wb.SaveAs(ms); + return ms.ToArray(); + } + + // ─── Report 3: Overdue & Critical Tasks ─────────────────────────────────── + + public async Task>> GetOverdueCriticalTasksAsync( + string? search, + long? projectId, + long roleId, + long currentUserId, + int? priorityId = null, + string? delayType = null, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var now = DateTime.UtcNow; + var today = DateTime.Today; + var query = BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .Include(t => t.Project) + .Include(t => t.AssignedToNavigation) + .Where(t => t.IsDeleted != true && t.StatusId != AppTaskStatus.Done && + (t.DueDate < now || t.PriorityId == TaskPriority.High)); // overdue OR high priority + + if (projectId.HasValue && projectId > 0) + query = query.Where(t => t.ProjectId == projectId.Value); + + if (priorityId.HasValue && priorityId > 0) + query = query.Where(t => (int)t.PriorityId == priorityId.Value); + + switch (delayType?.Trim().ToLower()) + { + case "overdue": + query = query.Where(t => t.DueDate.Date < today); + break; + case "soon": + query = query.Where(t => t.DueDate.Date >= today && t.DueDate.Date <= today.AddDays(3)); + break; + } + + if (assignedToMe == true || assignedToMyTeam == true) + { + var teamIds = assignedToMyTeam == true ? await GetTeamUserIdsAsync(currentUserId) : new List(); + query = query.Where(t => + (assignedToMe == true && t.AssignedTo == currentUserId) || + (assignedToMyTeam == true && t.AssignedTo.HasValue && teamIds.Contains(t.AssignedTo.Value)) + ); + } + + var tasks = await query.OrderBy(t => t.DueDate).ToListAsync(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + tasks = tasks.Where(t => + t.Title.ToLower().Contains(s) || + (t.Project?.Name.ToLower().Contains(s) == true) + ).ToList(); + } + + var list = tasks.Select(t => new OverdueCriticalTaskDto + { + TaskId = t.Id, + Title = t.Title, + Description = t.Description, + ProjectName = t.Project?.Name ?? "-", + StatusName = StatusMap.TryGetValue(t.StatusId, out var s) ? s : $"Status {t.StatusId}", + PriorityName = PriorityMap.TryGetValue(t.PriorityId, out var p) ? p : $"Priority {t.PriorityId}", + AssignedTo = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null, + AssignedToUserId = t.AssignedTo, + DueDate = t.DueDate, + DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow + }).ToList(); + await EnrichOverdueTaskReportsAsync(list); + + return Result>.Success(list); + } + + public async Task> GetPagedOverdueCriticalTasksAsync( + string? search, + long? projectId, + long roleId, + long currentUserId, + int page, + int pageSize, + int? priorityId = null, + string? delayType = null, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var now = DateTime.UtcNow; + var today = DateTime.Today; + var query = BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId) + .Include(t => t.Project) + .Include(t => t.AssignedToNavigation) + .Where(t => t.IsDeleted != true && t.StatusId != AppTaskStatus.Done && + (t.DueDate < now || t.PriorityId == TaskPriority.High)); + + if (projectId.HasValue && projectId > 0) + query = query.Where(t => t.ProjectId == projectId.Value); + + if (priorityId.HasValue && priorityId > 0) + query = query.Where(t => (int)t.PriorityId == priorityId.Value); + + switch (delayType?.Trim().ToLower()) + { + case "overdue": + query = query.Where(t => t.DueDate.Date < today); + break; + case "soon": + query = query.Where(t => t.DueDate.Date >= today && t.DueDate.Date <= today.AddDays(3)); + break; + default: + query = query.Where(t => t.DueDate.Date <= today.AddDays(3)); + break; + } + + if (assignedToMe == true || assignedToMyTeam == true) + { + var teamIds = assignedToMyTeam == true ? await GetTeamUserIdsAsync(currentUserId) : new List(); + query = query.Where(t => + (assignedToMe == true && t.AssignedTo == currentUserId) || + (assignedToMyTeam == true && t.AssignedTo.HasValue && teamIds.Contains(t.AssignedTo.Value)) + ); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(t => + t.Title.ToLower().Contains(s) || + (t.Project != null && t.Project.Name.ToLower().Contains(s))); + } + + var paged = await query + .OrderBy(t => t.DueDate) + .ThenBy(t => t.Title) + .ToPagedResultAsync(page, pageSize); + + var mapped = MapPagedResult(paged, t => new OverdueCriticalTaskDto + { + TaskId = t.Id, + Title = t.Title, + Description = t.Description, + ProjectName = t.Project?.Name ?? "-", + StatusName = StatusMap.TryGetValue(t.StatusId, out var s) ? s : $"Status {t.StatusId}", + PriorityName = PriorityMap.TryGetValue(t.PriorityId, out var p) ? p : $"Priority {t.PriorityId}", + AssignedTo = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName}" : null, + AssignedToUserId = t.AssignedTo, + DueDate = t.DueDate, + DaysOverdue = t.DueDate < now ? (int)(now - t.DueDate).TotalDays : 0, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow + }); + await EnrichOverdueTaskReportsAsync(mapped.Items); + + return mapped; + } + + public byte[] ExportOverdueCriticalToExcel(IEnumerable data) + { + using var wb = new XLWorkbook(); + var ws = wb.Worksheets.Add("Overdue & Critical Tasks"); + var headers = new[] { "Task ID", "Title", "Project", "Status", "Priority", "Assigned To", "Due Date", "Days Overdue", "Created At" }; + for (int i = 0; i < headers.Length; i++) + { + ws.Cell(1, i + 1).Value = headers[i]; + ws.Cell(1, i + 1).Style.Font.Bold = true; + ws.Cell(1, i + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DC2626"); + ws.Cell(1, i + 1).Style.Font.FontColor = XLColor.White; + } + int row = 2; + foreach (var item in data) + { + ws.Cell(row, 1).Value = item.TaskId; + ws.Cell(row, 2).Value = item.Title; + ws.Cell(row, 3).Value = item.ProjectName; + ws.Cell(row, 4).Value = item.StatusName; + ws.Cell(row, 5).Value = item.PriorityName; + ws.Cell(row, 6).Value = item.AssignedTo ?? "Unassigned"; + ws.Cell(row, 7).Value = DisplayFormats.Date(item.DueDate); + ws.Cell(row, 8).Value = item.DaysOverdue; + ws.Cell(row, 9).Value = DisplayFormats.Date(item.CreatedAt); + if (item.DaysOverdue > 0) ws.Row(row).Style.Fill.BackgroundColor = XLColor.FromHtml("#FEE2E2"); + row++; + } + ws.Columns().AdjustToContents(); + using var ms = new MemoryStream(); + wb.SaveAs(ms); + return ms.ToArray(); + } + + public async Task>> GetEmployeeProductivityReportAsync( + string? search, + DateTime? startDate, + DateTime? endDate, + string? status, + long roleId, + long currentUserId, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var users = await BuildAccessibleUserQuery(isAdmin, currentUserId).ToListAsync(); + + if (assignedToMe == true || assignedToMyTeam == true) + { + var teamIds = assignedToMyTeam == true ? await GetTeamUserIdsAsync(currentUserId) : new List(); + users = users.Where(u => + (assignedToMe == true && u.Id == currentUserId) || + (assignedToMyTeam == true && teamIds.Contains(u.Id)) + ).ToList(); + } + + var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync(); + var issues = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .Include(i => i.Task) + .ToListAsync(); + + if (startDate.HasValue) + { + tasks = tasks.Where(t => t.CreatedAt >= startDate.Value.Date).ToList(); + issues = issues.Where(i => (i.CreatedAt ?? DateTime.UtcNow) >= startDate.Value.Date).ToList(); + } + + if (endDate.HasValue) + { + tasks = tasks.Where(t => t.CreatedAt < endDate.Value.Date.AddDays(1)).ToList(); + issues = issues.Where(i => (i.CreatedAt ?? DateTime.UtcNow) < endDate.Value.Date.AddDays(1)).ToList(); + } + + if (!string.IsNullOrWhiteSpace(status)) + { + var sl = status.Trim().ToLower(); + if (sl == "overdue") + { + tasks = tasks.Where(t => t.StatusId != AppTaskStatus.Done && t.DueDate < DateTime.Today).ToList(); + issues = issues.Where(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < DateTime.Today).ToList(); + } + else if (sl != "0") + { + if (Enum.TryParse(status, true, out var parsedStatus)) + { + tasks = tasks.Where(t => t.StatusId == parsedStatus).ToList(); + issues = issues.Where(i => i.StatusId == parsedStatus).ToList(); + } + } + } + + var list = new List(); + foreach (var user in users) + { + var userTasks = tasks.Where(t => t.AssignedTo == user.Id).ToList(); + var userIssues = issues.Where(i => i.AssignedTo == user.Id).ToList(); + int total = userTasks.Count; + var completedTasks = userTasks.Where(t => t.StatusId == AppTaskStatus.Done).ToList(); + int done = completedTasks.Count; + var completedIssues = userIssues.Where(i => i.StatusId == AppTaskStatus.Done).ToList(); + + int onTimeCount = completedTasks.Count(t => + (t.UpdatedAt ?? t.CreatedAt) <= t.DueDate); + + var onTimeIssueCount = completedIssues.Count(i => (i.UpdatedAt ?? i.CreatedAt ?? DateTime.UtcNow) <= i.DueDate); + double onTimeDeliveryRate = completedIssues.Count > 0 + ? Math.Round(((double)onTimeIssueCount / completedIssues.Count) * 100, 2) + : done > 0 ? Math.Round(((double)onTimeCount / done) * 100, 2) : 0; + var openIssues = userIssues.Count(i => i.StatusId != AppTaskStatus.Done); + var overdueIssues = userIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < DateTime.Today); + var completionRate = userIssues.Count > 0 ? Math.Round(((double)completedIssues.Count / userIssues.Count) * 100, 2) : 0; + var avgCompletionDays = completedIssues.Count > 0 + ? Math.Round(completedIssues.Average(i => Math.Max(0, ((i.UpdatedAt ?? i.CreatedAt ?? DateTime.UtcNow).Date - i.StartDate.Date).TotalDays)), 1) + : 0; + + list.Add(new EmployeeProductivityReportDto + { + UserId = user.Id, + Username = user.Username, + FullName = $"{user.FirstName} {user.LastName}".Trim(), + AssignedCount = total, + CompletedCount = done, + AssignedIssues = userIssues.Count, + OpenIssues = openIssues, + OverdueIssues = overdueIssues, + ActualHours = userIssues.Sum(i => i.ActualHours ?? 0), + WorkloadLevel = openIssues >= 10 || overdueIssues >= 3 ? "High" : openIssues <= 2 ? "Light" : "Balanced", + AverageCompletionTimeDays = avgCompletionDays, + Trend = overdueIssues > 0 ? "Declining" : completionRate >= 80 ? "Improving" : "Stable", + CompletionRate = completionRate, + Efficiency = completionRate, + OnTimeDeliveryRate = onTimeDeliveryRate + }); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + list = list.Where(u => u.FullName.ToLower().Contains(s) || u.Username.ToLower().Contains(s)).ToList(); + } + + return Result>.Success(list.OrderByDescending(u => u.OpenIssues).ThenByDescending(u => u.ActualHours)); + } + + public async Task> GetPagedEmployeeProductivityReportAsync( + string? search, + DateTime? startDate, + DateTime? endDate, + string? status, + long roleId, + long currentUserId, + int page, + int pageSize, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var full = await GetEmployeeProductivityReportAsync(search, startDate, endDate, status, roleId, currentUserId, assignedToMe, assignedToMyTeam); + if (!full.IsSuccess || full.Value == null) + { + return new PagedResult(); + } + + var ordered = full.Value.OrderByDescending(u => u.OpenIssues).ThenByDescending(u => u.ActualHours).ToList(); + var normalizedPageSize = PaginationExtensions.NormalizePageSize(pageSize); + var totalCount = ordered.Count; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)normalizedPageSize); + var normalizedPage = Math.Max(page, 1); + if (totalPages > 0 && normalizedPage > totalPages) + normalizedPage = totalPages; + + var items = ordered + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToList(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + } + + public async Task>> GetProjectProgressReportAsync( + string? search, + DateTime? startDate, + DateTime? endDate, + string? status, + long roleId, + long currentUserId, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + var projects = await BuildAccessibleProjectQuery(isAdmin, currentUserId).ToListAsync(); + + if (assignedToMe == true || assignedToMyTeam == true) + { + var myProjectIds = await _db.ProjectMembers + .Where(pm => pm.UserId == currentUserId) + .Select(pm => pm.ProjectId) + .Distinct() + .ToListAsync(); + + projects = projects.Where(p => myProjectIds.Contains(p.Id) || p.CreatedById == currentUserId).ToList(); + } + + var tasks = await BuildAccessibleTaskQuery(isAdmin, isManager, currentUserId).ToListAsync(); + var issues = await BuildAccessibleIssueQuery(isAdmin, isManager, currentUserId) + .Include(i => i.Task) + .ToListAsync(); + var today = DateTime.Today; + + if (startDate.HasValue) + projects = projects.Where(p => p.StartDate >= startDate.Value.Date).ToList(); + + if (endDate.HasValue) + projects = projects.Where(p => p.StartDate < endDate.Value.Date.AddDays(1)).ToList(); + + var list = new List(); + foreach (var project in projects) + { + var projectTasks = tasks.Where(t => t.ProjectId == project.Id).ToList(); + var projectIssues = issues.Where(i => i.Task.ProjectId == project.Id).ToList(); + + if (!string.IsNullOrWhiteSpace(status)) + { + if (status.Equals("overdue", StringComparison.OrdinalIgnoreCase)) + { + projectTasks = projectTasks.Where(t => t.DueDate.Date < today && t.StatusId != AppTaskStatus.Done).ToList(); + projectIssues = projectIssues.Where(i => i.DueDate.Date < today && i.StatusId != AppTaskStatus.Done).ToList(); + } + else if (status != "0" && Enum.TryParse(status, true, out var parsedStatus)) + { + projectTasks = projectTasks.Where(t => t.StatusId == parsedStatus).ToList(); + projectIssues = projectIssues.Where(i => i.StatusId == parsedStatus).ToList(); + } + } + + int totalTasks = projectTasks.Count; + int completedTasks = projectTasks.Count(t => t.StatusId == AppTaskStatus.Done); + double progress = totalTasks > 0 ? ((double)completedTasks / totalTasks) * 100 : 0; + + double elapsedPct; + if (today < project.StartDate) elapsedPct = 0; + else if (today > project.EndDate) elapsedPct = 100; + else + { + var totalDays = (project.EndDate - project.StartDate).TotalDays; + var elapsedDays = (today - project.StartDate).TotalDays; + elapsedPct = totalDays > 0 ? (elapsedDays / totalDays) * 100 : 100; + } + + bool isAhead = progress > elapsedPct && progress > 0 && progress < 100; + var openIssues = projectIssues.Count(i => i.StatusId != AppTaskStatus.Done); + var overdueIssues = projectIssues.Count(i => i.StatusId != AppTaskStatus.Done && i.DueDate.Date < today); + var blockedIssues = projectIssues.Count(i => i.IsBlocked); + bool isAtRisk = (projectTasks.Any(t => t.StatusId != AppTaskStatus.Done && t.DueDate.Date < today)) || + overdueIssues > 0 || + blockedIssues > 0 || + (project.EndDate.Date < today && progress < 100); + var health = isAtRisk && (overdueIssues > 0 || blockedIssues > 0) ? "Critical" : isAtRisk ? "At Risk" : "Healthy"; + var lastActivity = projectIssues + .Select(i => i.UpdatedAt ?? i.CreatedAt ?? project.CreatedAt ?? DateTime.UtcNow) + .Concat(projectTasks.Select(t => t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow)) + .DefaultIfEmpty(project.CreatedAt ?? DateTime.UtcNow) + .Max(); + + list.Add(new ProjectProgressReportDto + { + ProjectId = project.Id, + ProjectName = project.Name, + StartDate = project.StartDate, + EndDate = project.EndDate, + TotalTasks = totalTasks, + CompletedTasks = completedTasks, + OpenIssues = openIssues, + OverdueIssues = overdueIssues, + BlockedIssues = blockedIssues, + Progress = progress, + IsAhead = isAhead, + IsAtRisk = isAtRisk, + ProjectHealth = health, + LastActivity = lastActivity, + NextMilestone = project.EndDate.Date >= today ? project.EndDate : null, + ActualHours = projectIssues.Sum(i => i.ActualHours ?? 0) + }); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + list = list.Where(p => p.ProjectName.ToLower().Contains(s)).ToList(); + } + + return Result>.Success(list.OrderBy(p => p.ProjectName)); + } + + public async Task> GetPagedProjectProgressReportAsync( + string? search, + DateTime? startDate, + DateTime? endDate, + string? status, + long roleId, + long currentUserId, + int page, + int pageSize, + bool? assignedToMe = null, + bool? assignedToMyTeam = null) + { + var full = await GetProjectProgressReportAsync(search, startDate, endDate, status, roleId, currentUserId, assignedToMe, assignedToMyTeam); + if (!full.IsSuccess || full.Value == null) + { + return new PagedResult(); + } + + var ordered = full.Value.OrderBy(p => p.ProjectName).ToList(); + var normalizedPageSize = PaginationExtensions.NormalizePageSize(pageSize); + var totalCount = ordered.Count; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)normalizedPageSize); + var normalizedPage = Math.Max(page, 1); + if (totalPages > 0 && normalizedPage > totalPages) + normalizedPage = totalPages; + + var items = ordered + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToList(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + } + } +} + diff --git a/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs b/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs new file mode 100644 index 0000000000000000000000000000000000000000..4dc4170baa936c0adf8c39c8fa60333b1fa32e83 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Role; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Role +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class RoleController : ControllerBase + { + private readonly RoleService _roleService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public RoleController(RoleService roleService, PermissionAuthorizationService permissionAuthorizationService) + { + _roleService = roleService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetRoles( + [FromQuery] string? search, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var roles = await _roleService.GetAllRolesAsync(); + return Ok(roles); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var paged = await _roleService.GetPagedRolesAsync(search, page, limit); + return Ok(paged); + } + + [HttpGet("{id}")] + public async Task> GetRole(long id) + { + var role = await _roleService.GetRoleByIdAsync(id); + if (role == null) + { + return NotFound(new { message = $"Role with ID {id} not found." }); + } + return Ok(role); + } + + [HttpPost] + public async Task>> CreateRole([FromBody] CreateRoleDto createRoleDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Create")) + { + return StatusCode(403, Result.Failure("You do not have permission to create roles.", 403)); + } + + long? currentUserId = User.GetUserId(); + var result = await _roleService.CreateRoleAsync(createRoleDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpPut("{id}")] + public async Task> UpdateRole(long id, [FromBody] UpdateRoleDto updateRoleDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update roles.", 403)); + } + + long? currentUserId = User.GetUserId(); + var result = await _roleService.UpdateRoleAsync(id, updateRoleDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("{id}")] + public async Task> DeleteRole(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Delete")) + { + return StatusCode(403, Result.Failure("You do not have permission to delete roles.", 403)); + } + + var result = await _roleService.SoftDeleteRoleAsync(id, User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + + [HttpGet("{id}/menus")] + [HttpGet("{id}/access")] + public async Task>>> GetAssignedAccessCodes(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Update")) + { + return StatusCode(403, Result>.Failure("You do not have permission to view role access.", 403)); + } + + var result = await _roleService.GetAssignedAccessCodesByRoleIdAsync(id); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); + } + return Ok(result); + } + + [HttpPost("{id}/menus")] + [HttpPost("{id}/access")] + public async Task AssignAccess(long id, [FromBody] AssignAccessDto dto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to assign role access.", 403)); + } + + var result = await _roleService.AssignAccessToRoleAsync(id, dto, User.GetUserId()); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); + } + return Ok(); + } + } +} + + + diff --git a/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs b/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs new file mode 100644 index 0000000000000000000000000000000000000000..b7a397019560dee0b4851b2d95c4409b12505fda --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs @@ -0,0 +1,524 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Role; +using TaskTrackingSystem.WebApi.Infrastructure; +using RoleEntity = TaskTrackingSystem.Database.AppDbContextModels.Role; + +namespace TaskTrackingSystem.WebApi.Features.Role +{ + public class RoleService + { + private readonly AppDbContext _db; + private readonly Infrastructure.AuditLogService _auditLog; + + public RoleService(AppDbContext db, Infrastructure.AuditLogService auditLog) + { + _db = db; + _auditLog = auditLog; + } + + public async Task> GetAllRolesAsync() + { + return await _db.Roles + .Where(r => r.IsDeleted != true) + .Select(r => new RoleDto + { + Id = r.Id, + Name = r.Name, + Description = r.Description, + CreatedAt = r.CreatedAt ?? DateTime.UtcNow + }) + .ToListAsync(); + } + + public async Task> GetPagedRolesAsync(string? search, int page, int pageSize) + { + var query = _db.Roles.Where(r => r.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchTerm = search.Trim().ToLower(); + query = query.Where(r => + (r.Name != null && r.Name.ToLower().Contains(searchTerm)) || + (r.Description != null && r.Description.ToLower().Contains(searchTerm))); + } + + return await query + .OrderBy(r => r.Name) + .Select(r => new RoleDto + { + Id = r.Id, + Name = r.Name, + Description = r.Description, + CreatedAt = r.CreatedAt ?? DateTime.UtcNow + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task GetRoleByIdAsync(long id) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true); + if (role == null) + { + return null; + } + + return new RoleDto + { + Id = role.Id, + Name = role.Name, + Description = role.Description, + CreatedAt = role.CreatedAt ?? DateTime.UtcNow + }; + } + + public async Task> CreateRoleAsync(CreateRoleDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return Result.Failure(ResultMessages.RoleNameRequired, 400); + } + + var nameExists = await _db.Roles.AnyAsync(r => r.Name == dto.Name); + if (nameExists) + { + return Result.Failure("Role name is already taken.", 400); + } + + if (dto.AccessCodes != null && dto.AccessCodes.Any()) + { + var validCodesResult = await ValidateAccessCodesAsync(dto.AccessCodes); + if (!validCodesResult.IsSuccess) + { + return Result.Failure(validCodesResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, validCodesResult.StatusCode); + } + } + + var role = new RoleEntity + { + Name = dto.Name, + Description = dto.Description, + CreatedAt = DateTime.UtcNow, + CreatedBy = currentUserId + }; + + _db.Roles.Add(role); + await _db.SaveChangesAsync(); + + await _auditLog.LogAsync("Create", "Role", $"Created new role '{role.Name}'"); + + var assignResult = await AssignAccessToRoleAsync( + role.Id, + new AssignAccessDto { AccessCodes = dto.AccessCodes ?? new List() }, + currentUserId); + if (!assignResult.IsSuccess) + { + return Result.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode); + } + + return Result.Success(new RoleDto + { + Id = role.Id, + Name = role.Name, + Description = role.Description, + CreatedAt = role.CreatedAt ?? DateTime.UtcNow + }, 201); + } + + public async Task UpdateRoleAsync(long id, UpdateRoleDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return Result.Failure(ResultMessages.RoleNameRequired, 400); + } + + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true); + if (role == null) + { + return Result.Failure(ResultMessages.RoleNotFound(id), 404); + } + + var nameExists = await _db.Roles.AnyAsync(r => r.Name == dto.Name && r.Id != id && r.IsDeleted != true); + if (nameExists) + { + return Result.Failure("Role name is already taken by another role.", 400); + } + + role.Name = dto.Name; + role.Description = dto.Description; + role.UpdatedAt = DateTime.UtcNow; + role.UpdatedBy = currentUserId; + + _db.Roles.Update(role); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Update", "Role", $"Updated role name/description for '{role.Name}'"); + return Result.Success(200); + } + + public async Task SoftDeleteRoleAsync(long id, long? currentUserId = null) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true); + if (role == null) + { + return Result.Failure(ResultMessages.RoleNotFound(id), 404); + } + + var usersCount = await _db.Users.CountAsync(u => u.RoleId == id && !u.IsDeleted); + if (usersCount > 0) + { + return Result.Failure($"you can't delete this role because there are {usersCount} users in this role.", 400); + } + + role.IsDeleted = true; + role.UpdatedAt = DateTime.UtcNow; + role.UpdatedBy = currentUserId; + _db.Roles.Update(role); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Delete", "Role", $"Deleted role '{role.Name}'"); + return Result.Success(200); + } + + public async Task>> GetAssignedAccessCodesByRoleIdAsync(long roleId) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); + if (role == null) + { + return Result>.Failure(ResultMessages.RoleNotFound(roleId), 404); + } + + var validMenuCodes = await _db.Menus + .Where(m => !m.IsDeleted) + .Select(m => m.MenuCode) + .ToListAsync(); + + var validPermissionCodes = await _db.Permissions + .Where(p => !p.IsDeleted) + .Select(p => p.PermissionCode) + .ToListAsync(); + + var validCodes = validMenuCodes + .Concat(validPermissionCodes) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var menuCodes = await _db.RoleMenus + .Where(rm => rm.RoleId == role.Id && !rm.IsDeleted) + .Select(rm => rm.Menu.MenuCode) + .ToListAsync(); + + var permissionCodes = await _db.RolePermissions + .Where(rp => rp.RoleId == role.Id && !rp.IsDeleted) + .Select(rp => rp.Permission.PermissionCode) + .ToListAsync(); + + var assignedCodes = menuCodes + .Concat(permissionCodes) + .Where(code => validCodes.Contains(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Result>.Success(assignedCodes); + } + + public async Task AssignAccessToRoleAsync(long roleId, AssignAccessDto dto, long? currentUserId = null) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); + if (role == null) + { + return Result.Failure(ResultMessages.RoleNotFound(roleId), 404); + } + + if (dto.AccessCodes == null) + { + return Result.Failure(ResultMessages.AccessCodesCannotBeNull, 400); + } + + var selectedCodes = dto.AccessCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var validCodesResult = await ValidateAccessCodesAsync(selectedCodes); + if (!validCodesResult.IsSuccess) + { + return Result.Failure(validCodesResult.ErrorMessage ?? ResultMessages.FailedToUpdateRole, validCodesResult.StatusCode); + } + + var selectedMenus = await _db.Menus + .Where(m => !m.IsDeleted && selectedCodes.Contains(m.MenuCode)) + .Select(m => new { m.MenuId, m.MenuCode, m.ParentMenuId }) + .ToListAsync(); + + var selectedPermissions = await _db.Permissions + .Where(p => !p.IsDeleted && selectedCodes.Contains(p.PermissionCode)) + .Select(p => new { p.PermissionId, p.PermissionCode, p.MenuId }) + .ToListAsync(); + + var menuLookup = await _db.Menus + .Where(m => !m.IsDeleted) + .Select(m => new { m.MenuId, m.ParentMenuId }) + .ToListAsync(); + + var menuIdLookup = menuLookup.ToDictionary(x => x.MenuId, x => x.ParentMenuId); + var menuIdsToPersist = new HashSet(); + var now = DateTime.UtcNow; + + foreach (var menu in selectedMenus) + { + AddWithAncestors(menu.MenuId, menuIdLookup, menuIdsToPersist); + } + + foreach (var permission in selectedPermissions) + { + AddWithAncestors(permission.MenuId, menuIdLookup, menuIdsToPersist); + } + + var permissionIdsToPersist = selectedPermissions + .Select(p => p.PermissionId) + .Distinct() + .ToList(); + + var existingRoleMenus = await _db.RoleMenus + .Where(rm => rm.RoleId == role.Id) + .OrderBy(rm => rm.RoleMenuId) + .ToListAsync(); + var existingRolePermissions = await _db.RolePermissions + .Where(rp => rp.RoleId == role.Id) + .OrderBy(rp => rp.RolePermissionId) + .ToListAsync(); + + foreach (var duplicateMenuGroup in existingRoleMenus.GroupBy(rm => rm.MenuId).Where(g => g.Count() > 1)) + { + foreach (var duplicate in duplicateMenuGroup.Skip(1)) + { + duplicate.IsDeleted = true; + duplicate.UpdatedAt = now; + duplicate.UpdatedById = currentUserId; + } + } + + foreach (var duplicatePermissionGroup in existingRolePermissions.GroupBy(rp => rp.PermissionId).Where(g => g.Count() > 1)) + { + foreach (var duplicate in duplicatePermissionGroup.Skip(1)) + { + duplicate.IsDeleted = true; + duplicate.UpdatedAt = now; + duplicate.UpdatedById = currentUserId; + } + } + + var oldMenuIds = existingRoleMenus + .Where(rm => !rm.IsDeleted) + .Select(rm => rm.MenuId) + .ToHashSet(); + var oldPermissionIds = existingRolePermissions + .Where(rp => !rp.IsDeleted) + .Select(rp => rp.PermissionId) + .ToHashSet(); + + var existingRoleMenusByMenuId = existingRoleMenus + .GroupBy(rm => rm.MenuId) + .ToDictionary(g => g.Key, g => g.First()); + var existingRolePermissionsByPermissionId = existingRolePermissions + .GroupBy(rp => rp.PermissionId) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var existingMenu in existingRoleMenus) + { + var shouldBeActive = menuIdsToPersist.Contains(existingMenu.MenuId); + + if (shouldBeActive) + { + if (existingMenu.IsDeleted) + { + existingMenu.IsDeleted = false; + existingMenu.UpdatedAt = now; + existingMenu.UpdatedById = currentUserId; + } + } + else if (!existingMenu.IsDeleted) + { + existingMenu.IsDeleted = true; + existingMenu.UpdatedAt = now; + existingMenu.UpdatedById = currentUserId; + } + } + + foreach (var menuId in menuIdsToPersist) + { + if (existingRoleMenusByMenuId.ContainsKey(menuId)) + { + continue; + } + + _db.RoleMenus.Add(new RoleMenu + { + RoleId = role.Id, + MenuId = menuId, + IsDeleted = false, + CreatedAt = now, + CreatedById = currentUserId + }); + } + + foreach (var existingPermission in existingRolePermissions) + { + var shouldBeActive = permissionIdsToPersist.Contains(existingPermission.PermissionId); + + if (shouldBeActive) + { + if (existingPermission.IsDeleted) + { + existingPermission.IsDeleted = false; + existingPermission.UpdatedAt = now; + existingPermission.UpdatedById = currentUserId; + } + } + else if (!existingPermission.IsDeleted) + { + existingPermission.IsDeleted = true; + existingPermission.UpdatedAt = now; + existingPermission.UpdatedById = currentUserId; + } + } + + foreach (var permissionId in permissionIdsToPersist) + { + if (existingRolePermissionsByPermissionId.ContainsKey(permissionId)) + { + continue; + } + + _db.RolePermissions.Add(new RolePermission + { + RoleId = role.Id, + PermissionId = permissionId, + IsDeleted = false, + CreatedAt = now, + CreatedById = currentUserId + }); + } + + // Build a human-readable diff of what changed + // Load all menu and permission names for diff display + var allMenuNames = await _db.Menus + .Where(m => !m.IsDeleted) + .Select(m => new { m.MenuId, m.MenuName, m.MenuCode }) + .ToListAsync(); + var allPermNames = await _db.Permissions + .Include(p => p.Menu) + .Where(p => !p.IsDeleted) + .Select(p => new { p.PermissionId, p.PermissionCode, p.ActionName, MenuName = p.Menu.MenuName }) + .ToListAsync(); + + var menuNameLookup = allMenuNames.ToDictionary(m => m.MenuId, m => m.MenuName); + var permNameLookup = allPermNames.ToDictionary(p => p.PermissionId, + p => $"{p.MenuName} — {p.ActionName}"); + + var addedMenuNames = menuIdsToPersist + .Except(oldMenuIds) + .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) + .OrderBy(n => n) + .ToList(); + + var removedMenuNames = oldMenuIds + .Except(menuIdsToPersist) + .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) + .OrderBy(n => n) + .ToList(); + + var addedPermNames = permissionIdsToPersist + .Except(oldPermissionIds) + .Select(id => permNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) + .OrderBy(n => n) + .ToList(); + + var removedPermNames = oldPermissionIds + .Except(new HashSet(permissionIdsToPersist)) + .Select(id => permNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) + .OrderBy(n => n) + .ToList(); + + var descParts = new System.Text.StringBuilder(); + descParts.Append($"Updated access permissions for role '{role.Name}'."); + + if (addedMenuNames.Any() || addedPermNames.Any()) + { + var added = addedMenuNames.Concat(addedPermNames).OrderBy(n => n).ToList(); + descParts.Append($" Granted: {string.Join(", ", added)}."); + } + + if (removedMenuNames.Any() || removedPermNames.Any()) + { + var removed = removedMenuNames.Concat(removedPermNames).OrderBy(n => n).ToList(); + descParts.Append($" Revoked: {string.Join(", ", removed)}."); + } + + if (!addedMenuNames.Any() && !addedPermNames.Any() && !removedMenuNames.Any() && !removedPermNames.Any()) + { + descParts.Append(" No changes detected."); + } + + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("AssignAccess", "Role", descParts.ToString()); + return Result.Success(200); + } + + private static void AddWithAncestors(long menuId, IReadOnlyDictionary parentLookup, ISet target) + { + var current = menuId; + + while (current > 0 && target.Add(current)) + { + if (!parentLookup.TryGetValue(current, out var parentId) || !parentId.HasValue) + { + break; + } + + current = parentId.Value; + } + } + + private async Task ValidateAccessCodesAsync(IEnumerable codes) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .ToList(); + + var validAccessCodes = await _db.Menus + .Where(m => !m.IsDeleted) + .Select(m => m.MenuCode) + .ToListAsync(); + + var validPermissionCodes = await _db.Permissions + .Where(p => !p.IsDeleted) + .Select(p => p.PermissionCode) + .ToListAsync(); + + var validCodes = validAccessCodes + .Concat(validPermissionCodes) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var invalidCodes = normalizedCodes + .Where(code => !validCodes.Contains(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (invalidCodes.Any()) + { + return Result.Failure(ResultMessages.InvalidAccessCodes(string.Join(", ", invalidCodes)), 400); + } + + return Result.Success(200); + } + } +} + + + diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs new file mode 100644 index 0000000000000000000000000000000000000000..c23f7e2b7e8e2db64743d91c8043939e50defd24 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Task; +using TaskTrackingSystem.WebApi.Features.Task; +using TaskTrackingSystem.WebApi.Infrastructure; +using TaskTrackingSystem.Shared.Enums; + +namespace TaskTrackingSystem.WebApi.Features.Task +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class TaskController : ControllerBase + { + private readonly TaskService _taskService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public TaskController(TaskService taskService, PermissionAuthorizationService permissionAuthorizationService) + { + _taskService = taskService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetTasks( + [FromQuery] string? search, + [FromQuery] long? projectId, + [FromQuery] AppTaskStatus? statusId, + [FromQuery] TaskPriority? priorityId, + [FromQuery] bool assignedOnly = false, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var tasks = await _taskService.GetAllTasksAsync(User.GetRoleId(), User.GetUserId()); + return Ok(tasks); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var paged = await _taskService.GetPagedTasksAsync( + User.GetRoleId(), + User.GetUserId(), + search, + projectId, + statusId, + priorityId, + assignedOnly, + page, + limit); + + return Ok(paged); + } + + [HttpGet("archived")] + public async Task>> GetArchivedTasks() + { + var tasks = await _taskService.GetArchivedTasksAsync(User.GetRoleId(), User.GetUserId()); + return Ok(tasks); + } + + [HttpGet("{id}")] + public async Task> GetTask(long id) + { + var task = await _taskService.GetTaskByIdAsync(id, User.GetRoleId(), User.GetUserId()); + if (task == null) + { + return NotFound(new { message = $"Task with ID {id} not found." }); + } + return Ok(task); + } + + [HttpPost] + public async Task>> CreateTask([FromBody] CreateTaskDto createTaskDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Create")) + { + return StatusCode(403, Result.Failure("You do not have permission to create tasks.", 403)); + } + + var currentUserId = User.GetUserId(); + var result = await _taskService.CreateTaskAsync(createTaskDto, User.GetRoleId(), currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpPut("{id}")] + public async Task> UpdateTask(long id, [FromBody] UpdateTaskDto updateTaskDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update tasks.", 403)); + } + + long? currentUserId = User.GetUserId(); + var result = await _taskService.UpdateTaskAsync(id, updateTaskDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("{id}")] + public async Task> DeleteTask(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Delete")) + { + return StatusCode(403, Result.Failure("You do not have permission to delete tasks.", 403)); + } + + var result = await _taskService.SoftDeleteTaskAsync(id, User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("/api/User/{userId}/tasks")] + public async Task>>> GetUserTasks(long userId) + { + var result = await _taskService.GetTasksByUserIdAsync(userId, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpPatch("{id}/status")] + public async Task UpdateTaskStatus(long id, [FromQuery] AppTaskStatus statusId) + { + var result = await _taskService.UpdateTaskStatusAsync(id, statusId, User.GetRoleId(), User.GetUserId()); + if (!result.IsSuccess) + { + return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); + } + return Ok(result); + } + + [HttpPatch("archive-done")] + public async Task>> ArchiveDoneTasks([FromQuery] long? projectId) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to archive tasks.", 403)); + } + + var result = await _taskService.ArchiveDoneTasksAsync(projectId, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs new file mode 100644 index 0000000000000000000000000000000000000000..4d6b486c4af13bfc75bd0d152a3dc2b306078572 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs @@ -0,0 +1,146 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Comment; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Task +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class TaskDetailsController : ControllerBase + { + private readonly AppDbContext _db; + private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService; + + public TaskDetailsController(AppDbContext db, TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService) + { + _db = db; + _notificationService = notificationService; + } + + // ─── COMMENTS ──────────────────────────────────────────────────────── + + [HttpGet("tasks/{taskId}/comments")] + public async Task>> GetComments(long taskId) + { + var taskExists = await _db.Tasks.AnyAsync(t => t.Id == taskId && !t.IsDeleted && !t.IsArchived); + if (!taskExists) + { + return NotFound(new { message = $"Task with ID {taskId} not found." }); + } + + var comments = await _db.Comments + .Include(c => c.User) + .ThenInclude(u => u.Role) + .Where(c => c.TaskId == taskId && !c.IsDeleted) + .OrderBy(c => c.CreatedAt) + .Select(c => new CommentDto + { + Id = c.Id, + TaskId = c.TaskId, + UserId = c.UserId, + UserFullName = $"{c.User.FirstName} {c.User.LastName}", + UserRoleName = c.User.Role != null ? c.User.Role.Name : "User", + Message = c.Message, + CreatedAt = c.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = c.UpdatedAt + }) + .ToListAsync(); + + return Ok(comments); + } + + [HttpPost("tasks/{taskId}/comments")] + public async Task>> CreateComment(long taskId, [FromBody] CreateCommentDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Message)) + { + return BadRequest(Result.Failure("Comment message cannot be empty.", 400)); + } + + var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId && !t.IsDeleted && !t.IsArchived); + if (task == null) + { + return NotFound(Result.Failure($"Task with ID {taskId} not found.", 404)); + } + + var userId = User.GetUserId(); + var user = await _db.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user == null) + { + return Unauthorized(); + } + + var comment = new Comment + { + TaskId = taskId, + UserId = userId, + Message = dto.Message.Trim(), + CreatedAt = DateTime.UtcNow, + CreatedBy = userId, + IsDeleted = false + }; + + _db.Comments.Add(comment); + await _db.SaveChangesAsync(); + + var actorName = $"{user.FirstName} {user.LastName}"; + await _notificationService.NotifyCommentAddedAsync(task, userId, actorName, comment.Message); + await _notificationService.NotifyMentionedAsync(task, userId, actorName, comment.Message); + + var resultDto = new CommentDto + { + Id = comment.Id, + TaskId = comment.TaskId, + UserId = comment.UserId, + UserFullName = $"{user.FirstName} {user.LastName}", + UserRoleName = user.Role != null ? user.Role.Name : "User", + Message = comment.Message, + CreatedAt = comment.CreatedAt ?? DateTime.UtcNow + }; + + return StatusCode(201, Result.Success(resultDto, 201)); + } + + [HttpDelete("comments/{commentId}")] + public async Task> DeleteComment(long commentId) + { + var comment = await _db.Comments.FirstOrDefaultAsync(c => c.Id == commentId && !c.IsDeleted); + if (comment == null) + { + return NotFound(Result.Failure("Comment not found.", 404)); + } + + var userId = User.GetUserId(); + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, User.GetRoleId()); + + // Only comment creator or Admin can delete + if (comment.UserId != userId && !isAdmin) + { + return Forbid(); + } + + comment.IsDeleted = true; + comment.UpdatedAt = DateTime.UtcNow; + comment.UpdatedBy = userId; + + _db.Comments.Update(comment); + await _db.SaveChangesAsync(); + + return Ok(Result.Success(200)); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs new file mode 100644 index 0000000000000000000000000000000000000000..0f7f7c0c24d47e645c6234b8c1857d2bb9c9ed4a --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs @@ -0,0 +1,460 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; +using TaskTrackingSystem.Shared.Models.Task; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Task +{ + public class TaskService + { + private readonly AppDbContext _db; + private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService; + private readonly AuditLogService _auditLog; + + public TaskService( + AppDbContext db, + TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService, + AuditLogService auditLog) + { + _db = db; + _notificationService = notificationService; + _auditLog = auditLog; + } + + private static DateTime? GetCompletedAt(TaskTrackingSystem.Database.AppDbContextModels.Task task) + { + return task.StatusId == AppTaskStatus.Done + ? task.UpdatedAt ?? task.CreatedAt + : null; + } + + private static DateTime ToUtcDate(DateTime value) + { + return value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + } + + public async Task> GetAllTasksAsync(long roleId, long currentUserId) + { + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + return await query + .OrderBy(t => t.DueDate) + .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + StatusId = t.StatusId, + PriorityId = t.PriorityId, + AssignedTo = t.AssignedTo, + AssignedBy = t.AssignedBy, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + IsArchived = t.IsArchived, + CompletedAt = GetCompletedAt(t) + }) + .ToListAsync(); + } + + public async Task> GetPagedTasksAsync( + long roleId, + long currentUserId, + string? search, + long? projectId, + AppTaskStatus? statusId, + TaskPriority? priorityId, + bool assignedOnly, + int page, + int pageSize) + { + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + + if (assignedOnly) + { + query = query.Where(t => t.AssignedTo == currentUserId); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchTerm = search.Trim().ToLower(); + query = query.Where(t => + (t.Title != null && t.Title.ToLower().Contains(searchTerm)) || + (t.Description != null && t.Description.ToLower().Contains(searchTerm))); + } + + if (projectId.HasValue && projectId.Value > 0) + { + query = query.Where(t => t.ProjectId == projectId.Value); + } + + if (statusId.HasValue) + { + query = query.Where(t => t.StatusId == statusId.Value); + } + + if (priorityId.HasValue) + { + query = query.Where(t => t.PriorityId == priorityId.Value); + } + + return await query + .OrderBy(t => t.DueDate) + .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + StatusId = t.StatusId, + PriorityId = t.PriorityId, + AssignedTo = t.AssignedTo, + AssignedBy = t.AssignedBy, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + IsArchived = t.IsArchived, + CompletedAt = GetCompletedAt(t) + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task> GetArchivedTasksAsync(long roleId, long currentUserId) + { + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId, includeArchived: true); + return await query + .Where(t => t.IsArchived) + .OrderByDescending(t => t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + StatusId = t.StatusId, + PriorityId = t.PriorityId, + AssignedTo = t.AssignedTo, + AssignedBy = t.AssignedBy, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + IsArchived = t.IsArchived, + CompletedAt = GetCompletedAt(t) + }) + .ToListAsync(); + } + + public async Task GetTaskByIdAsync(long id, long roleId, long currentUserId) + { + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + var task = await query.FirstOrDefaultAsync(t => t.Id == id); + + if (task == null) + { + return null; + } + + return new TaskDto + { + Id = task.Id, + Title = task.Title, + Description = task.Description, + ProjectId = task.ProjectId, + StatusId = task.StatusId, + PriorityId = task.PriorityId, + AssignedTo = task.AssignedTo, + AssignedBy = task.AssignedBy, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt ?? DateTime.UtcNow, + IsArchived = task.IsArchived, + CompletedAt = GetCompletedAt(task) + }; + } + + public async Task> CreateTaskAsync(CreateTaskDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return Result.Failure(ResultMessages.TaskTitleRequired, 400); + } + + if (dto.ProjectId == 0) + { + return Result.Failure(ResultMessages.SelectProjectRequired, 400); + } + + var projectExists = await _db.Projects.AnyAsync(p => p.Id == dto.ProjectId && p.IsDeleted != true); + if (!projectExists) + { + return Result.Failure(ResultMessages.ProjectNotFound(dto.ProjectId), 404); + } + + if (!await CanAccessProjectAsync(dto.ProjectId, roleId, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + + if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(dto.ProjectId, dto.AssignedTo.Value)) + { + return Result.Failure("Task assignee must belong to the selected project.", 400); + } + + var task = new TaskTrackingSystem.Database.AppDbContextModels.Task + { + Title = dto.Title, + Description = dto.Description, + ProjectId = dto.ProjectId, + StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId, + PriorityId = dto.PriorityId == 0 ? TaskPriority.Medium : dto.PriorityId, + AssignedTo = dto.AssignedTo, + AssignedBy = dto.AssignedBy, + DueDate = ToUtcDate(dto.DueDate), + IsDeleted = false, + IsArchived = false, + CreatedAt = DateTime.UtcNow, + CreatedBy = currentUserId + }; + + _db.Tasks.Add(task); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Create", "Task", $"Created task '{task.Title}'"); + + if (task.AssignedTo.HasValue) + { + await _notificationService.NotifyTaskAssignedAsync(task, currentUserId); + } + + var resultDto = new TaskDto + { + Id = task.Id, + Title = task.Title, + Description = task.Description, + ProjectId = task.ProjectId, + StatusId = task.StatusId, + PriorityId = task.PriorityId, + AssignedTo = task.AssignedTo, + AssignedBy = task.AssignedBy, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt ?? DateTime.UtcNow, + CompletedAt = null, + IsArchived = task.IsArchived + }; + + return Result.Success(resultDto, 201); + } + + public async Task UpdateTaskAsync(long id, UpdateTaskDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return Result.Failure(ResultMessages.TaskTitleRequired, 400); + } + + var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true && !t.IsArchived); + if (task == null) + { + return Result.Failure(ResultMessages.TaskNotFound(id), 404); + } + + var previousStatusId = task.StatusId; + var previousAssignedTo = task.AssignedTo; + + if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(task.ProjectId, dto.AssignedTo.Value)) + { + return Result.Failure("Task assignee must belong to the selected project.", 400); + } + + task.Title = dto.Title; + task.Description = dto.Description; + task.StatusId = dto.StatusId; + task.PriorityId = dto.PriorityId; + task.AssignedTo = dto.AssignedTo; + task.AssignedBy = dto.AssignedBy; + task.DueDate = ToUtcDate(dto.DueDate); + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + + _db.Tasks.Update(task); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Update", "Task", $"Updated task '{task.Title}'"); + + if (previousAssignedTo != task.AssignedTo && task.AssignedTo.HasValue && currentUserId.HasValue) + { + await _notificationService.NotifyTaskAssignedAsync(task, currentUserId.Value); + } + + if (previousStatusId != dto.StatusId && currentUserId.HasValue) + { + await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId.Value, previousStatusId, dto.StatusId); + } + + return Result.Success(200); + } + + public async Task SoftDeleteTaskAsync(long id, long currentUserId) + { + var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true && !t.IsArchived); + if (task == null) + { + return Result.Failure(ResultMessages.TaskNotFound(id), 404); + } + + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + _db.Tasks.Update(task); + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Delete", "Task", $"Deleted task '{task.Title}'"); + return Result.Success(200); + } + + public async Task>> GetTasksByUserIdAsync(long userId, long roleId, long currentUserId) + { + var userExists = await _db.Users.AnyAsync(u => u.Id == userId && !u.IsDeleted); + if (!userExists) + { + return Result>.Failure(ResultMessages.UserNotFound(userId), 404); + } + + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + var tasks = await query + .Where(t => t.AssignedTo == userId) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + Description = t.Description, + ProjectId = t.ProjectId, + StatusId = t.StatusId, + PriorityId = t.PriorityId, + AssignedTo = t.AssignedTo, + AssignedBy = t.AssignedBy, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + IsArchived = t.IsArchived, + CompletedAt = GetCompletedAt(t) + }) + .ToListAsync(); + + return Result>.Success(tasks); + } + + public async Task UpdateTaskStatusAsync(long id, AppTaskStatus statusId, long roleId, long currentUserId) + { + var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true && !t.IsArchived); + if (task == null) + { + return Result.Failure(ResultMessages.TaskNotFound(id), 404); + } + + var previousStatusId = task.StatusId; + + task.StatusId = statusId; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + + _db.Tasks.Update(task); + await _db.SaveChangesAsync(); + + if (previousStatusId != statusId) + { + await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId, previousStatusId, statusId); + } + + return Result.Success(200); + } + + public async Task> ArchiveDoneTasksAsync(long? projectId, long roleId, long currentUserId) + { + if (projectId.HasValue && projectId.Value > 0) + { + var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId.Value && p.IsDeleted != true); + if (!projectExists) + { + return Result.Failure(ResultMessages.ProjectNotFound(projectId.Value), 404); + } + + if (!await CanAccessProjectAsync(projectId.Value, roleId, currentUserId)) + { + return Result.Failure("You do not have access to this project.", 403); + } + } + + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + var tasksToArchive = await query + .Where(t => t.StatusId == AppTaskStatus.Done) + .ToListAsync(); + + if (projectId.HasValue && projectId.Value > 0) + { + tasksToArchive = tasksToArchive.Where(t => t.ProjectId == projectId.Value).ToList(); + } + + foreach (var task in tasksToArchive) + { + task.IsArchived = true; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + } + + if (tasksToArchive.Count > 0) + { + _db.Tasks.UpdateRange(tasksToArchive); + await _db.SaveChangesAsync(); + } + + return Result.Success(tasksToArchive.Count); + } + + private async Task> BuildAccessibleTaskQueryAsync(long roleId, long currentUserId, bool includeArchived = false) + { + var query = _db.Tasks.Where(t => t.IsDeleted != true && (includeArchived || !t.IsArchived)); + + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (isAdmin) + { + return query; + } + + var isManager = await DataScopeAuthorization.IsManagerScopeAsync(_db, roleId); + if (isManager) + { + return query.Where(t => + t.AssignedTo == currentUserId || + t.CreatedBy == currentUserId || + t.Project.ProjectMembers.Any(pm => pm.UserId == currentUserId)); + } + + return query.Where(t => + t.AssignedTo == currentUserId || + t.CreatedBy == currentUserId); + } + + private async Task CanAccessProjectAsync(long projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (isAdmin) + { + return await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true); + } + + return await _db.Projects.AnyAsync(p => + p.Id == projectId && + p.IsDeleted != true && + (p.CreatedById == currentUserId || p.ProjectMembers.Any(pm => pm.UserId == currentUserId))); + } + + private async Task IsProjectMemberAsync(long projectId, long userId) + { + return await _db.ProjectMembers.AnyAsync(pm => pm.ProjectId == projectId && pm.UserId == userId); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/User/UserController.cs b/TaskTrackingSystem.WebApi/Features/User/UserController.cs new file mode 100644 index 0000000000000000000000000000000000000000..1e766b28d362e53aee73c01080b4aa98e9111ada --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/User/UserController.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.User; +using TaskTrackingSystem.WebApi.Features.User; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.User +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class UserController : ControllerBase + { + private readonly UserService _userService; + private readonly PermissionAuthorizationService _permissionAuthorizationService; + + public UserController(UserService userService, PermissionAuthorizationService permissionAuthorizationService) + { + _userService = userService; + _permissionAuthorizationService = permissionAuthorizationService; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetUsers( + [FromQuery] string? search, + [FromQuery] long? roleId, + [FromQuery] string? status, + [FromQuery] PaginationQuery? paging = null) + { + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var users = await _userService.GetAllUsersAsync(); + return Ok(users); + } + + var page = PaginationExtensions.NormalizePage(paging.Page); + var limit = PaginationExtensions.NormalizePageSize(paging.Limit ?? 0); + var paged = await _userService.GetPagedUsersAsync(search, roleId, status, page, limit); + return Ok(paged); + } + + /// Returns the user IDs of all colleagues who share any project with the current user. + [HttpGet("my-team-user-ids")] + public async Task>> GetMyTeamUserIds() + { + var currentUserId = User.GetUserId(); + var myProjectIds = await _userService.GetMyProjectIdsAsync(currentUserId); + var teamUserIds = await _userService.GetTeamUserIdsAsync(myProjectIds, currentUserId); + return Ok(teamUserIds); + } + + /// Returns all project IDs the current user is a member of. + [HttpGet("my-project-ids")] + public async Task>> GetMyProjectIds() + { + var currentUserId = User.GetUserId(); + var projectIds = await _userService.GetMyProjectIdsAsync(currentUserId); + return Ok(projectIds); + } + + [HttpGet("{id}")] + public async Task> GetUser(long id) + { + var user = await _userService.GetUserByIdAsync(id); + if (user == null) + { + return NotFound(new { message = $"User with ID {id} not found." }); + } + return Ok(user); + } + + [HttpPost] + public async Task>> CreateUser([FromBody] CreateUserDto createUserDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/User", "Create")) + { + return StatusCode(403, Result.Failure("You do not have permission to create users.", 403)); + } + + long? currentUserId = null; + var result = await _userService.CreateUserAsync(createUserDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpPut("{id}")] + public async Task> UpdateUser(long id, [FromBody] UpdateUserDto updateUserDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/User", "Update")) + { + return StatusCode(403, Result.Failure("You do not have permission to update users.", 403)); + } + + long? currentUserId = null; + var result = await _userService.UpdateUserAsync(id, updateUserDto, currentUserId); + return StatusCode(result.StatusCode, result); + } + + [HttpDelete("{id}")] + public async Task> DeleteUser(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/User", "Delete")) + { + return StatusCode(403, Result.Failure("You do not have permission to delete users.", 403)); + } + + long? loggedInUserId = null; + var nameIdentifier = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (nameIdentifier != null && long.TryParse(nameIdentifier, out var parsedId)) + { + loggedInUserId = parsedId; + } + + var result = await _userService.SoftDeleteUserAsync(id, loggedInUserId); + return StatusCode(result.StatusCode, result); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/User/UserService.cs b/TaskTrackingSystem.WebApi/Features/User/UserService.cs new file mode 100644 index 0000000000000000000000000000000000000000..ad831f56878ae329aafb80fe3acd51cfa8ce726c --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/User/UserService.cs @@ -0,0 +1,246 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TaskTrackingSystem.Database; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.User; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.User +{ + public class UserService + { + private readonly AppDbContext _db; + private readonly IPasswordHasher _passwordHasher; + private readonly Infrastructure.AuditLogService _auditLog; + + public UserService(AppDbContext db, IPasswordHasher passwordHasher, Infrastructure.AuditLogService auditLog) + { + _db = db; + _passwordHasher = passwordHasher; + _auditLog = auditLog; + } + + public async Task> GetAllUsersAsync() + { + return await _db.Users + .Where(u => !u.IsDeleted) + .Select(u => new UserDto + { + Id = u.Id, + Username = u.Username, + FirstName = u.FirstName, + LastName = u.LastName, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }).ToListAsync(); + } + + public async Task> GetPagedUsersAsync(string? search, long? roleId, string? status, int page, int pageSize) + { + var query = _db.Users.Where(u => !u.IsDeleted); + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchTerm = search.Trim().ToLower(); + query = query.Where(u => + (u.FirstName != null && u.FirstName.ToLower().Contains(searchTerm)) || + (u.LastName != null && u.LastName.ToLower().Contains(searchTerm)) || + (u.Username != null && u.Username.ToLower().Contains(searchTerm)) || + (u.Email != null && u.Email.ToLower().Contains(searchTerm))); + } + + if (roleId.HasValue && roleId.Value > 0) + { + query = query.Where(u => u.RoleId == roleId.Value); + } + + if (string.Equals(status, "active", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(u => u.IsActive); + } + else if (string.Equals(status, "inactive", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(u => !u.IsActive); + } + + return await query + .OrderBy(u => u.FirstName) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Username) + .Select(u => new UserDto + { + Id = u.Id, + Username = u.Username, + FirstName = u.FirstName, + LastName = u.LastName, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task GetUserByIdAsync(long id) + { + var user = await _db.Users + .FirstOrDefaultAsync(u => u.Id == id && !u.IsDeleted); + + if (user == null) return null; + + return new UserDto + { + Id = user.Id, + Username = user.Username, + FirstName = user.FirstName, + LastName = user.LastName, + Email = user.Email, + Phone = user.Phone, + RoleId = user.RoleId, + IsActive = user.IsActive, + CreatedAt = user.CreatedAt ?? DateTime.UtcNow + }; + } + + public async Task> CreateUserAsync(CreateUserDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.FirstName) || string.IsNullOrWhiteSpace(dto.LastName) || string.IsNullOrWhiteSpace(dto.Email)) + { + return Result.Failure(ResultMessages.FillAllFields, 400); + } + + var usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username && !u.IsDeleted); + if (usernameExists) + { + return Result.Failure("Username is already taken.", 400); + } + + var emailExists = await _db.Users.AnyAsync(u => u.Email == dto.Email && !u.IsDeleted); + if (emailExists) + { + return Result.Failure("Email is already registered.", 400); + } + + var user = new TaskTrackingSystem.Database.AppDbContextModels.User + { + Username = dto.Username, + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + PasswordHash = string.Empty, + Phone = dto.Phone, + RoleId = dto.RoleId, + IsActive = true, + CreatedAt = DateTime.UtcNow, + CreatedBy = currentUserId + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password); + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + var roleName = await _db.Roles.Where(r => r.Id == user.RoleId).Select(r => r.Name).FirstOrDefaultAsync() ?? "Unknown"; + await _auditLog.LogAsync("Create", "User", $"Created user '{user.Username}' ({user.FirstName} {user.LastName}, {user.Email}) with role '{roleName}'"); + + var resultDto = new UserDto + { + Id = user.Id, + Username = user.Username, + FirstName = user.FirstName, + LastName = user.LastName, + Email = user.Email, + Phone = user.Phone, + RoleId = user.RoleId, + IsActive = user.IsActive, + CreatedAt = user.CreatedAt ?? DateTime.UtcNow + }; + + return Result.Success(resultDto, 201); + } + + public async Task UpdateUserAsync(long id, UpdateUserDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.FirstName) || string.IsNullOrWhiteSpace(dto.LastName)) + { + return Result.Failure(ResultMessages.FillAllFields, 400); + } + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id && !u.IsDeleted); + if (user == null) return Result.Failure(ResultMessages.UserNotFound(id), 404); + + var usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username && u.Id != id && !u.IsDeleted); + if (usernameExists) + { + return Result.Failure("Username is already taken by another user.", 400); + } + + user.Username = dto.Username; + user.FirstName = dto.FirstName; + user.LastName = dto.LastName; + user.Phone = dto.Phone; + user.RoleId = dto.RoleId; + user.IsActive = dto.IsActive; + user.UpdatedAt = DateTime.UtcNow; + user.UpdatedBy = currentUserId; + + _db.Users.Update(user); + await _db.SaveChangesAsync(); + + var roleName = await _db.Roles.Where(r => r.Id == user.RoleId).Select(r => r.Name).FirstOrDefaultAsync() ?? "Unknown"; + var statusLabel = user.IsActive ? "Active" : "Inactive"; + await _auditLog.LogAsync("Update", "User", $"Updated user '{user.Username}' ({user.FirstName} {user.LastName}) — Role: '{roleName}', Status: {statusLabel}"); + + return Result.Success(200); + } + + public async Task SoftDeleteUserAsync(long id, long? loggedInUserId = null) + { + if (loggedInUserId.HasValue && loggedInUserId.Value == id) + { + return Result.Failure("You cannot delete your own account.", 400); + } + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id && !u.IsDeleted); + if (user == null) return Result.Failure(ResultMessages.UserNotFound(id), 404); + + user.IsDeleted = true; + _db.Users.Update(user); + await _db.SaveChangesAsync(); + + await _auditLog.LogAsync("Delete", "User", $"Deleted user account '{user.Username}' ({user.FirstName} {user.LastName})"); + + return Result.Success(200); + } + + public async Task> GetMyProjectIdsAsync(long currentUserId) + { + return await _db.ProjectMembers + .Where(pm => pm.UserId == currentUserId) + .Select(pm => pm.ProjectId) + .Distinct() + .ToListAsync(); + } + + public async Task> GetTeamUserIdsAsync(List projectIds, long currentUserId) + { + if (projectIds == null || projectIds.Count == 0) + return new List(); + + return await _db.ProjectMembers + .Where(pm => projectIds.Contains(pm.ProjectId) && pm.UserId != currentUserId) + .Select(pm => pm.UserId) + .Distinct() + .ToListAsync(); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs new file mode 100644 index 0000000000000000000000000000000000000000..585ea2112a7e2055259874d092a666be88b5b1ae --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using DbUserDevice = TaskTrackingSystem.Database.AppDbContextModels.UserDevice; + +namespace TaskTrackingSystem.WebApi.Features.UserDevice; + +public class UserDeviceService +{ + private readonly AppDbContext _db; + + public UserDeviceService(AppDbContext db) + { + _db = db; + } + + public async Task RegisterTokenAsync(long userId, string fcmToken) + { + var token = fcmToken.Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return Result.Failure("FCM token is required.", 400); + } + + var existing = await _db.UserDevices.FirstOrDefaultAsync(x => x.FcmToken == token); + var now = DateTime.UtcNow; + + if (existing == null) + { + _db.UserDevices.Add(new DbUserDevice + { + UserId = userId, + FcmToken = token, + CreatedAt = now, + UpdatedAt = now + }); + } + else + { + existing.UserId = userId; + existing.UpdatedAt = now; + } + + await _db.SaveChangesAsync(); + return Result.Success(); + } + +} diff --git a/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs new file mode 100644 index 0000000000000000000000000000000000000000..ad2425846fd33c2bb8983b04f5e21264a2b13630 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Notification; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.UserDevice; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class UserDevicesController : ControllerBase +{ + private readonly UserDeviceService _userDeviceService; + + public UserDevicesController(UserDeviceService userDeviceService) + { + _userDeviceService = userDeviceService; + } + + [HttpPost("register")] + public async Task> Register([FromBody] RegisterDeviceTokenDto dto) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var result = await _userDeviceService.RegisterTokenAsync(userId, dto.FcmToken); + return StatusCode(result.StatusCode, result); + } + +} diff --git a/TaskTrackingSystem.WebApi/Infrastructure/AuditLogService.cs b/TaskTrackingSystem.WebApi/Infrastructure/AuditLogService.cs new file mode 100644 index 0000000000000000000000000000000000000000..ba9637594966b0489fd5d5ed73506e7e346f9e3e --- /dev/null +++ b/TaskTrackingSystem.WebApi/Infrastructure/AuditLogService.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using TaskTrackingSystem.Database.AppDbContextModels; + +namespace TaskTrackingSystem.WebApi.Infrastructure +{ + public class AuditLogService + { + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AuditLogService(AppDbContext db, IHttpContextAccessor httpContextAccessor) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + } + + public async System.Threading.Tasks.Task LogAsync(string action, string module, string description) + { + try + { + var httpContext = _httpContextAccessor.HttpContext; + long? userId = null; + string? ipAddress = null; + + if (httpContext != null) + { + var user = httpContext.User; + if (user != null) + { + var id = user.GetUserId(); + if (id > 0) + { + userId = id; + } + } + + // Get remote IP address + ipAddress = httpContext.Connection?.RemoteIpAddress?.ToString(); + + // Fallback to check X-Forwarded-For if behind reverse proxy + if (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) + { + ipAddress = forwardedFor.ToString().Split(',')[0].Trim(); + } + } + + var log = new AuditLog + { + UserId = userId, + Action = action, + Module = module, + Description = description, + IpAddress = ipAddress, + CreatedAt = DateTime.UtcNow + }; + + _db.AuditLogs.Add(log); + await _db.SaveChangesAsync(); + } + catch (Exception ex) + { + // Never let audit logging failure block critical operations + Console.WriteLine($"[AuditLog Error] Failed to write audit log: {ex.Message}"); + } + } + } +} diff --git a/TaskTrackingSystem.WebApi/Infrastructure/ClaimsPrincipalExtensions.cs b/TaskTrackingSystem.WebApi/Infrastructure/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000000000000000000000000000000000..e41b69cbb0838e04e25b64bc5fb0ca3c04d0e841 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Infrastructure/ClaimsPrincipalExtensions.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace TaskTrackingSystem.WebApi.Infrastructure; + +public static class ClaimsPrincipalExtensions +{ + public static long GetUserId(this ClaimsPrincipal user) + { + var value = user.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(value, out var id) ? id : 0; + } + + public static long GetRoleId(this ClaimsPrincipal user) + { + var value = user.FindFirstValue("role_id"); + return long.TryParse(value, out var id) ? id : 0; + } + + public static string GetRoleName(this ClaimsPrincipal user) + { + return user.FindFirstValue(ClaimTypes.Role) ?? string.Empty; + } + + + +} diff --git a/TaskTrackingSystem.WebApi/Infrastructure/DataScopeAuthorization.cs b/TaskTrackingSystem.WebApi/Infrastructure/DataScopeAuthorization.cs new file mode 100644 index 0000000000000000000000000000000000000000..3cea63cdb57f27ca8e2b2db97e20927d069bab27 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Infrastructure/DataScopeAuthorization.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using System.Threading.Tasks; +using TaskTrackingSystem.Database.AppDbContextModels; + +namespace TaskTrackingSystem.WebApi.Infrastructure +{ + public static class DataScopeAuthorization + { + public static async Task IsAdminScopeAsync(AppDbContext db, long roleId) + { + if (roleId <= 0) return false; + return await db.RolePermissions.AnyAsync(rp => + rp.RoleId == roleId && + !rp.IsDeleted && + rp.Permission.PermissionCode == "Roles_List" && + !rp.Permission.IsDeleted); + } + + public static async Task IsManagerScopeAsync(AppDbContext db, long roleId) + { + if (roleId <= 0) return false; + return await db.RolePermissions.AnyAsync(rp => + rp.RoleId == roleId && + !rp.IsDeleted && + rp.Permission.PermissionCode == "Projects_Create" && + !rp.Permission.IsDeleted); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Infrastructure/PaginationExtensions.cs b/TaskTrackingSystem.WebApi/Infrastructure/PaginationExtensions.cs new file mode 100644 index 0000000000000000000000000000000000000000..3178580707616e52c18509ca7cf1d70e6d3037eb --- /dev/null +++ b/TaskTrackingSystem.WebApi/Infrastructure/PaginationExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Shared; + +namespace TaskTrackingSystem.WebApi.Infrastructure; + +public static class PaginationExtensions +{ + public static int NormalizePage(int? page) + { + return page.HasValue && page.Value > 0 ? page.Value : 1; + } + + public static async Task> ToPagedResultAsync( + this IQueryable query, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + pageSize = NormalizePageSize(pageSize); + page = Math.Max(page, 1); + + var totalCount = await query.CountAsync(cancellationToken); + var totalPages = totalCount == 0 + ? 0 + : (int)Math.Ceiling(totalCount / (double)pageSize); + + if (totalPages > 0 && page > totalPages) + { + page = totalPages; + } + + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + TotalPages = totalPages + }; + } + + public static int NormalizePageSize(int pageSize) + { + if (pageSize <= 0) + { + return 20; + } + + return Math.Clamp(pageSize, 1, 100); + } +} diff --git a/TaskTrackingSystem.WebApi/Infrastructure/PermissionAuthorizationService.cs b/TaskTrackingSystem.WebApi/Infrastructure/PermissionAuthorizationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9e48c5bc1530b94e7646eb1f390d6963f79c8bf3 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Infrastructure/PermissionAuthorizationService.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using TaskTrackingSystem.Database.AppDbContextModels; + +namespace TaskTrackingSystem.WebApi.Infrastructure; + +public class PermissionAuthorizationService +{ + private readonly AppDbContext _db; + + public PermissionAuthorizationService(AppDbContext db) + { + _db = db; + } + + public async Task CanAccessAsync(ClaimsPrincipal user, string apiName, string actionName) + { + if (user.Identity?.IsAuthenticated != true) + { + return false; + } + + var roleId = await ResolveRoleIdAsync(user); + if (roleId <= 0) + { + return false; + } + + var roleName = user.GetRoleName(); + if (string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (await _db.Roles.AnyAsync(r => + r.Id == roleId && + r.IsDeleted != true && + r.Name != null && + r.Name.ToLower() == "admin")) + { + return true; + } + + return await _db.RolePermissions + .AnyAsync(rp => + rp.RoleId == roleId && + !rp.IsDeleted && + !rp.Permission.IsDeleted && + rp.Permission.ApiName == apiName && + rp.Permission.ActionName == actionName); + } + + private async Task ResolveRoleIdAsync(ClaimsPrincipal user) + { + var roleId = user.GetRoleId(); + if (roleId > 0) + { + return roleId; + } + + var roleName = user.GetRoleName(); + if (string.IsNullOrWhiteSpace(roleName)) + { + return 0; + } + + return await _db.Roles + .Where(r => r.Name == roleName && r.IsDeleted != true) + .Select(r => r.Id) + .FirstOrDefaultAsync(); + } +} diff --git a/TaskTrackingSystem.WebApi/Program.cs b/TaskTrackingSystem.WebApi/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..9b68b6eaeb941947afd352f1fac8191cc447bada --- /dev/null +++ b/TaskTrackingSystem.WebApi/Program.cs @@ -0,0 +1,540 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using TaskTrackingSystem.Database.AppDbContextModels; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using TaskTrackingSystem.Shared; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +builder.Services.AddHttpClient(); +builder.Services.AddSignalR(); + +// CORS policy for WebApp +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowWebApp", policy => + { + policy.WithOrigins("http://localhost:5247", "https://localhost:7176") + .AllowCredentials() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddScoped, PasswordHasher>(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddAuthorization(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Paste the JWT token from the login response. You can include or omit the 'Bearer ' prefix." + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); +}); +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + var jwtSettings = builder.Configuration.GetSection("JwtSettings"); + var key = jwtSettings["Key"] ?? throw new InvalidOperationException("JwtSettings:Key must be configured."); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"] ?? throw new InvalidOperationException("JwtSettings:Issuer must be configured."), + ValidAudience = jwtSettings["Audience"] ?? throw new InvalidOperationException("JwtSettings:Audience must be configured."), + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)) + }; + options.Events = new JwtBearerEvents + { + OnChallenge = async context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + Result.Failure("Your session has expired. Please log out and log in again.", 401)); + }, + OnForbidden = async context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + Result.Failure("You do not have permission to perform this action.", 403)); + } + }; +}); + +builder.Services.AddDbContext(opt => +{ + opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); +}); + +var app = builder.Build(); + +await EnsureSeedDataAsync(app); + + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.EnablePersistAuthorization(); + }); +} +else +{ + app.UseExceptionHandler(errorApp => + { + errorApp.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + Result.Failure("The server hit an error while processing the request.", 500)); + }); + }); +} + +app.UseCors("AllowWebApp"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapGet("/version", () => Results.Ok(new +{ + status = "ok", + build = "auth-json-a93c81d", + timestamp = "2026-07-24T06:35:00Z" +})); +app.MapGet("/api/version", () => Results.Ok(new +{ + status = "ok", + build = "cached-token-3438c94", + timestamp = "2026-07-24T06:45:00Z" +})); +app.MapControllers(); +app.MapHub("/hubs/notifications"); + +app.Run(); + +static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await db.Database.EnsureCreatedAsync(); + // In Supabase/Hugging Face, EnsureCreatedAsync() might skip table creation + // because other schemas (auth, storage, etc.) contain tables. + // We check if the 'Menus' table exists in the public schema, and force creation of tables if it doesn't. + var databaseCreator = db.Database.GetService() as RelationalDatabaseCreator; + if (databaseCreator != null) + { + var connection = db.Database.GetDbConnection(); + var connectionOpened = false; + if (connection.State != System.Data.ConnectionState.Open) + { + await db.Database.OpenConnectionAsync(); + connectionOpened = true; + } + try + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'Menus');"; + var tableExists = (bool)(await command.ExecuteScalarAsync() ?? false); + if (!tableExists) + { + await databaseCreator.CreateTablesAsync(); + } + } + finally + { + if (connectionOpened) + { + await db.Database.CloseConnectionAsync(); + } + } + } + + await EnsureReportUpgradeSchemaAsync(db); + + + + var dashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD"); + if (dashboardMenu == null) + { + dashboardMenu = new Menu + { + MenuCode = "DASHBOARD", + MenuName = "Dashboard", + MenuUrl = null, + Icon = "layout-grid", + Visible = false, + OrderNo = 0, + IsDeleted = true, + CreatedAt = DateTime.UtcNow + }; + db.Menus.Add(dashboardMenu); + await db.SaveChangesAsync(); + } + else + { + dashboardMenu.MenuName = "Dashboard"; + dashboardMenu.MenuUrl = null; + dashboardMenu.Icon = "layout-grid"; + dashboardMenu.Visible = false; + dashboardMenu.OrderNo = 0; + dashboardMenu.IsDeleted = true; + dashboardMenu.UpdatedAt = DateTime.UtcNow; + } + + var adminDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_ADMIN"); + if (adminDashboardMenu == null) + { + adminDashboardMenu = new Menu + { + MenuCode = "DASHBOARD_ADMIN", + ParentMenuId = null, + MenuName = "Dashboard", + MenuUrl = "/dashboard", + Icon = "layout-dashboard", + Visible = true, + OrderNo = 0, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }; + db.Menus.Add(adminDashboardMenu); + } + else + { + adminDashboardMenu.ParentMenuId = null; + adminDashboardMenu.MenuName = "Dashboard"; + adminDashboardMenu.MenuUrl = "/dashboard"; + adminDashboardMenu.Icon = "layout-dashboard"; + adminDashboardMenu.Visible = true; + adminDashboardMenu.OrderNo = 0; + adminDashboardMenu.IsDeleted = false; + adminDashboardMenu.UpdatedAt = DateTime.UtcNow; + } + + var managerDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_MANAGER"); + if (managerDashboardMenu == null) + { + managerDashboardMenu = new Menu + { + MenuCode = "DASHBOARD_MANAGER", + ParentMenuId = null, + MenuName = "Dashboard", + MenuUrl = "/dashboard/manager", + Icon = "layout-dashboard", + Visible = true, + OrderNo = 0, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }; + db.Menus.Add(managerDashboardMenu); + } + else + { + managerDashboardMenu.ParentMenuId = null; + managerDashboardMenu.MenuName = "Dashboard"; + managerDashboardMenu.MenuUrl = "/dashboard/manager"; + managerDashboardMenu.Icon = "layout-dashboard"; + managerDashboardMenu.Visible = true; + managerDashboardMenu.OrderNo = 0; + managerDashboardMenu.IsDeleted = false; + managerDashboardMenu.UpdatedAt = DateTime.UtcNow; + } + + var employeeDashboardMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "DASHBOARD_EMPLOYEE"); + if (employeeDashboardMenu == null) + { + employeeDashboardMenu = new Menu + { + MenuCode = "DASHBOARD_EMPLOYEE", + ParentMenuId = null, + MenuName = "Dashboard", + MenuUrl = "/dashboard/employee", + Icon = "layout-dashboard", + Visible = true, + OrderNo = 0, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }; + db.Menus.Add(employeeDashboardMenu); + } + else + { + employeeDashboardMenu.ParentMenuId = null; + employeeDashboardMenu.MenuName = "Dashboard"; + employeeDashboardMenu.MenuUrl = "/dashboard/employee"; + employeeDashboardMenu.Icon = "layout-dashboard"; + employeeDashboardMenu.Visible = true; + employeeDashboardMenu.OrderNo = 0; + employeeDashboardMenu.IsDeleted = false; + employeeDashboardMenu.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(); + + var adminRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Admin" && !r.IsDeleted); + if (adminRole != null) + { + var adminDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == adminRole.Id && rm.MenuId == adminDashboardMenu.MenuId); + if (adminDashboardAccess == null) + { + db.RoleMenus.Add(new RoleMenu + { + RoleId = adminRole.Id, + MenuId = adminDashboardMenu.MenuId, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }); + } + else + { + adminDashboardAccess.IsDeleted = false; + adminDashboardAccess.UpdatedAt = DateTime.UtcNow; + } + } + + var managerRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Manager" && !r.IsDeleted); + if (managerRole != null) + { + var managerDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == managerRole.Id && rm.MenuId == managerDashboardMenu.MenuId); + if (managerDashboardAccess == null) + { + db.RoleMenus.Add(new RoleMenu + { + RoleId = managerRole.Id, + MenuId = managerDashboardMenu.MenuId, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }); + } + else + { + managerDashboardAccess.IsDeleted = false; + managerDashboardAccess.UpdatedAt = DateTime.UtcNow; + } + } + + var employeeRole = await db.Roles.FirstOrDefaultAsync(r => r.Name == "Employee" && !r.IsDeleted); + if (employeeRole != null) + { + var employeeDashboardAccess = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == employeeRole.Id && rm.MenuId == employeeDashboardMenu.MenuId); + if (employeeDashboardAccess == null) + { + db.RoleMenus.Add(new RoleMenu + { + RoleId = employeeRole.Id, + MenuId = employeeDashboardMenu.MenuId, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }); + } + else + { + employeeDashboardAccess.IsDeleted = false; + employeeDashboardAccess.UpdatedAt = DateTime.UtcNow; + } + } + + await db.SaveChangesAsync(); + + await EnsureIssueReportMenuAsync(db); + + // Soft-delete existing tasks that belong to deleted projects + var orphanedTasks = await db.Tasks + .Where(t => t.IsDeleted != true && t.Project.IsDeleted == true) + .ToListAsync(); + foreach (var task in orphanedTasks) + { + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + } + if (orphanedTasks.Any()) + { + db.Tasks.UpdateRange(orphanedTasks); + } + + await db.SaveChangesAsync(); + + await ResetPostgresSequencesAsync(db); +} + +static async System.Threading.Tasks.Task ResetPostgresSequencesAsync(AppDbContext db) +{ + await db.Database.ExecuteSqlRawAsync(@" +DO $$ +DECLARE + sequence_record RECORD; + max_value BIGINT; +BEGIN + FOR sequence_record IN + SELECT + sequence_namespace.nspname AS sequence_schema, + sequence_class.relname AS sequence_name, + table_namespace.nspname AS table_schema, + table_class.relname AS table_name, + table_attribute.attname AS column_name + FROM pg_class sequence_class + JOIN pg_namespace sequence_namespace ON sequence_namespace.oid = sequence_class.relnamespace + JOIN pg_depend dependency ON dependency.objid = sequence_class.oid + JOIN pg_class table_class ON table_class.oid = dependency.refobjid + JOIN pg_namespace table_namespace ON table_namespace.oid = table_class.relnamespace + JOIN pg_attribute table_attribute + ON table_attribute.attrelid = table_class.oid + AND table_attribute.attnum = dependency.refobjsubid + WHERE sequence_class.relkind = 'S' + AND table_namespace.nspname = 'public' + AND dependency.deptype IN ('a', 'i') + LOOP + EXECUTE format( + 'SELECT MAX(%I)::bigint FROM %I.%I', + sequence_record.column_name, + sequence_record.table_schema, + sequence_record.table_name) + INTO max_value; + + IF max_value IS NULL THEN + EXECUTE format( + 'SELECT setval(%L::regclass, 1, false)', + format('%I.%I', sequence_record.sequence_schema, sequence_record.sequence_name)); + ELSE + EXECUTE format( + 'SELECT setval(%L::regclass, %s, true)', + format('%I.%I', sequence_record.sequence_schema, sequence_record.sequence_name), + max_value); + END IF; + END LOOP; +END $$; +"); +} + +static async System.Threading.Tasks.Task EnsureReportUpgradeSchemaAsync(AppDbContext db) +{ + await db.Database.ExecuteSqlRawAsync(@" +ALTER TABLE ""Issues"" ADD COLUMN IF NOT EXISTS ""DelayReason"" character varying(300); + +ALTER TABLE ""Issues"" ADD COLUMN IF NOT EXISTS ""IsBlocked"" boolean NOT NULL DEFAULT FALSE; + +ALTER TABLE ""Issues"" ADD COLUMN IF NOT EXISTS ""BlockedBy"" character varying(200); + +ALTER TABLE ""Issues"" ADD COLUMN IF NOT EXISTS ""EscalationLevel"" integer NOT NULL DEFAULT 0; +"); +} +static async System.Threading.Tasks.Task EnsureIssueReportMenuAsync(AppDbContext db) +{ + var reportsMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "REPORTS" && !m.IsDeleted); + var issueReportMenu = await db.Menus.FirstOrDefaultAsync(m => m.MenuCode == "REPORTS_ISSUES"); + + if (issueReportMenu == null) + { + issueReportMenu = new Menu + { + MenuCode = "REPORTS_ISSUES", + ParentMenuId = reportsMenu?.MenuId, + MenuName = "Issue Report", + MenuUrl = "/reports/issues", + Icon = "file-pen-line", + Visible = true, + OrderNo = 15, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }; + db.Menus.Add(issueReportMenu); + await db.SaveChangesAsync(); + } + else + { + issueReportMenu.ParentMenuId = reportsMenu?.MenuId; + issueReportMenu.MenuName = "Issue Report"; + issueReportMenu.MenuUrl = "/reports/issues"; + issueReportMenu.Icon = "file-pen-line"; + issueReportMenu.Visible = true; + issueReportMenu.OrderNo = 15; + issueReportMenu.IsDeleted = false; + issueReportMenu.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + + var reportRoles = await db.Roles + .Where(role => !role.IsDeleted && (role.Name == "Admin" || role.Name == "Manager" || role.Name == "Employee")) + .ToListAsync(); + + foreach (var role in reportRoles) + { + var roleMenu = await db.RoleMenus.FirstOrDefaultAsync(rm => rm.RoleId == role.Id && rm.MenuId == issueReportMenu.MenuId); + if (roleMenu == null) + { + db.RoleMenus.Add(new RoleMenu + { + RoleId = role.Id, + MenuId = issueReportMenu.MenuId, + IsDeleted = false, + CreatedAt = DateTime.UtcNow + }); + } + else + { + roleMenu.IsDeleted = false; + roleMenu.UpdatedAt = DateTime.UtcNow; + } + } + + await db.SaveChangesAsync(); +} diff --git a/TaskTrackingSystem.WebApi/Properties/launchSettings.json b/TaskTrackingSystem.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..8ac415debbb77e0f0c96acaed5d6e4466c704d84 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61652", + "sslPort": 44308 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7215;http://localhost:5018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj new file mode 100644 index 0000000000000000000000000000000000000000..7948ef1d1f3f0daa9aed16bdb285f6e86a0130d3 --- /dev/null +++ b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + diff --git a/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.http b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.http new file mode 100644 index 0000000000000000000000000000000000000000..910659ba7ef04a33dad630e0be0239759245eab3 --- /dev/null +++ b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.http @@ -0,0 +1,6 @@ +@TaskTrackingSystem.WebApi_HostAddress = http://localhost:5018 + +GET {{TaskTrackingSystem.WebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/TaskTrackingSystem.WebApi/appsettings.Development.json b/TaskTrackingSystem.WebApi/appsettings.Development.json new file mode 100644 index 0000000000000000000000000000000000000000..9a7deb199d33aa171390b63c625dc477b7a2242c --- /dev/null +++ b/TaskTrackingSystem.WebApi/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Firebase": { + "ServiceAccountPath": "Firebase/ttsfirebasekey.json" + } +} diff --git a/TaskTrackingSystem.WebApi/appsettings.json b/TaskTrackingSystem.WebApi/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..7b7101b37930e9b52c149dd548405b98beeb65a5 --- /dev/null +++ b/TaskTrackingSystem.WebApi/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtSettings": { + "Key": "YourSuperSecretKeyThatIsVeryLongAndSecure12345!", + "Issuer": "TaskTrackingSystem", + "Audience": "Users" + }, + "PasswordReset": { + "RecoveryCode": "TASKIFY-RESET-2026" + }, + "Firebase": { + "ServiceAccountPath": "Firebase/ttsfirebasekey.json" + }, + "ConnectionStrings": { + "DefaultConnection": "Host=aws-1-ap-southeast-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.rhsxiwuhmodoxuaeokpl;Password=TaskTrackingSystem;SSL Mode=Require;Trust Server Certificate=true" + } +} diff --git a/TaskTrackingSystem.WebApp/AccountEndpoints.cs b/TaskTrackingSystem.WebApp/AccountEndpoints.cs new file mode 100644 index 0000000000000000000000000000000000000000..caf72de96a9afe2e21fa6a2effd152d3bd68f086 --- /dev/null +++ b/TaskTrackingSystem.WebApp/AccountEndpoints.cs @@ -0,0 +1,377 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Auth; +using TaskTrackingSystem.WebApp.Components.Partial; + +namespace TaskTrackingSystem.WebApp; + +public static class AccountEndpoints +{ + public static void MapAccountEndpoints(this WebApplication app) + { + app.MapPost("/account/login", LoginAsync); + app.MapPost("/account/register", RegisterAsync); + app.MapPost("/account/reset-password", ResetPasswordAsync); + app.MapGet("/account/logout", LogoutAsync); + app.MapPost("/account/logout", LogoutAsync); + } + + private static async Task LoginAsync( + HttpContext context, + IHttpClientFactory httpClientFactory, + MenuAuthorizationService menuAuthorization, + [FromForm] string usernameOrEmail, + [FromForm] string password, + [FromForm] bool? rememberMe, + [FromForm] string? returnUrl) + { + if (string.IsNullOrWhiteSpace(usernameOrEmail) || string.IsNullOrWhiteSpace(password)) + { + return RedirectToLogin(returnUrl, "invalid"); + } + + HttpResponseMessage response; + try + { + var client = httpClientFactory.CreateClient("WebApi"); + response = await client.PostAsJsonAsync("Auth/login", new LoginDto + { + UsernameOrEmail = usernameOrEmail, + Password = password + }); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) + { + return RedirectToLogin(returnUrl, "unavailable"); + } + + if (!response.IsSuccessStatusCode) + { + return RedirectToLogin(returnUrl, "invalid"); + } + + var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (result?.IsSuccess != true || result.Value == null || string.IsNullOrWhiteSpace(result.Value.Username)) + { + return RedirectToLogin(returnUrl, "invalid"); + } + + await SignInUserAsync(context, result.Value, rememberMe ?? false); + var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value); + return Results.Redirect(GetSafeReturnUrl(returnUrl, landingPage)); + } + + private static async Task RegisterAsync( + HttpContext context, + IHttpClientFactory httpClientFactory, + MenuAuthorizationService menuAuthorization, + [FromForm] RegisterDto registerDto) + { + HttpResponseMessage response; + try + { + var client = httpClientFactory.CreateClient("WebApi"); + response = await client.PostAsJsonAsync("Auth/register", registerDto); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) + { + return Results.Redirect($"/register?error={Uri.EscapeDataString("Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running.")}"); + } + + string errorMessage = ResultMessages.RegistrationFailed; + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (result?.IsSuccess == true && result.Value != null && !string.IsNullOrWhiteSpace(result.Value.Username)) + { + await SignInUserAsync(context, result.Value, false); + var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value); + return Results.Redirect(GetSafeReturnUrl(null, landingPage)); + } + else if (result != null && !string.IsNullOrEmpty(result.ErrorMessage)) + { + errorMessage = result.ErrorMessage; + } + } + else + { + try + { + var contentString = await response.Content.ReadAsStringAsync(); + + // Try parsing as Result + try + { + var result = JsonSerializer.Deserialize>(contentString, Serialization.CaseInsensitive); + if (result != null && !string.IsNullOrEmpty(result.ErrorMessage)) + { + errorMessage = result.ErrorMessage; + } + } + catch + { + // Ignore and fall back to checking validation errors + } + + // If no errorMessage from Result, try parsing standard validation errors + if (errorMessage == ResultMessages.RegistrationFailed) + { + try + { + using var doc = JsonDocument.Parse(contentString); + var root = doc.RootElement; + if (root.TryGetProperty("errors", out var errorsProp) && errorsProp.ValueKind == JsonValueKind.Object) + { + var errorsList = new List(); + foreach (var prop in errorsProp.EnumerateObject()) + { + foreach (var val in prop.Value.EnumerateArray()) + { + errorsList.Add(val.GetString() ?? ""); + } + } + if (errorsList.Count > 0) + { + errorMessage = string.Join(" ", errorsList.Where(s => !string.IsNullOrEmpty(s))); + } + } + else if (root.TryGetProperty("errorMessage", out var errMsgProp)) + { + errorMessage = errMsgProp.GetString() ?? errorMessage; + } + } + catch + { + // Ignore and keep fallback + } + } + } + catch + { + // Fallback to default + } + } + + return Results.Redirect($"/register?error={Uri.EscapeDataString(errorMessage)}"); + } + + private static async Task ResetPasswordAsync( + HttpContext context, + IHttpClientFactory httpClientFactory, + [FromForm] ResetPasswordDto resetPasswordDto) + { + HttpResponseMessage response; + try + { + var client = httpClientFactory.CreateClient("WebApi"); + response = await client.PostAsJsonAsync("Auth/reset-password", resetPasswordDto); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) + { + return Results.Redirect($"/reset-password?error={Uri.EscapeDataString("Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running.")}"); + } + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (result?.IsSuccess == true) + { + return Results.Redirect("/login?reset=true"); + } + } + + var errorMessage = "Failed to reset password."; + if (response.Content != null) + { + try + { + var contentString = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(contentString, Serialization.CaseInsensitive); + if (result != null && !string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + errorMessage = result.ErrorMessage; + } + } + catch + { + // Keep default error message. + } + } + + return Results.Redirect($"/reset-password?error={Uri.EscapeDataString(errorMessage)}"); + } + + private static async Task LogoutAsync(HttpContext context, UserSessionState sessionState) + { + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + context.Response.Cookies.Delete(CookieAuthenticationDefaults.AuthenticationScheme); + sessionState.ClearSession(); + return Results.Redirect("/login?loggedOut=true"); + } + + private static async Task SignInUserAsync(HttpContext context, AuthResponseDto authResult, bool rememberMe) + { + var claims = new List + { + new(ClaimTypes.Name, authResult.Username), + new(ClaimTypes.Email, authResult.Email) + }; + + if (!string.IsNullOrWhiteSpace(authResult.RoleName)) + { + claims.Add(new Claim(ClaimTypes.Role, authResult.RoleName)); + } + + if (authResult.RoleId > 0) + { + claims.Add(new Claim("role_id", authResult.RoleId.ToString())); + } + + var userId = TryGetUserIdFromJwt(authResult.Token); + if (!string.IsNullOrWhiteSpace(userId)) + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + } + + claims.Add(new Claim("jwt_token", authResult.Token)); + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal, + new AuthenticationProperties + { + IsPersistent = rememberMe, + ExpiresUtc = rememberMe ? DateTimeOffset.UtcNow.AddDays(7) : DateTimeOffset.UtcNow.AddHours(8) + }); + } + + private static string? TryGetUserIdFromJwt(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + try + { + var parts = token.Split('.'); + if (parts.Length < 2) + { + return null; + } + + var payload = parts[1]; + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + + var json = Convert.FromBase64String(payload); + var data = JsonSerializer.Deserialize>(json); + if (data == null) + { + return null; + } + + if (data.TryGetValue("nameid", out var nameId)) + { + return nameId.GetString(); + } + + if (data.TryGetValue(ClaimTypes.NameIdentifier, out var fullNameId)) + { + return fullNameId.GetString(); + } + } + catch + { + return null; + } + + return null; + } + + private static IResult RedirectToLogin(string? returnUrl, string error) + { + var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) + ? string.Empty + : $"&ReturnUrl={Uri.EscapeDataString(returnUrl)}"; + + return Results.Redirect($"/login?error={error}{safeReturnUrl}"); + } + + private static string GetSafeReturnUrl(string? returnUrl, string landingPage) + { + if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith('/') || returnUrl.StartsWith("//")) + { + return landingPage; + } + + return returnUrl; + } + + private static async Task ResolveLandingPageAsync(MenuAuthorizationService menuAuthorization, AuthResponseDto authResult) + { + var principal = BuildTemporaryPrincipal(authResult); + var menus = await menuAuthorization.GetUserMenusAsync(principal); + var dashboardMenu = FindFirstDashboardMenu(menus); + return dashboardMenu ?? "/dashboard"; + } + + private static ClaimsPrincipal BuildTemporaryPrincipal(AuthResponseDto authResult) + { + var claims = new List + { + new(ClaimTypes.Name, authResult.Username), + new(ClaimTypes.Email, authResult.Email), + new(ClaimTypes.Role, authResult.RoleName), + new("role_id", authResult.RoleId.ToString()), + new("jwt_token", authResult.Token) + }; + + return new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); + } + + private static string? FindFirstDashboardMenu(IEnumerable menus) + { + foreach (var menu in menus) + { + var found = FindFirstDashboardMenu(menu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return null; + } + + private static string? FindFirstDashboardMenu(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu) + { + if (!string.IsNullOrWhiteSpace(menu.MenuUrl) && + menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase)) + { + return menu.MenuUrl; + } + + foreach (var subMenu in menu.SubMenus) + { + var found = FindFirstDashboardMenu(subMenu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return null; + } +} diff --git a/TaskTrackingSystem.WebApp/ApiClientService.cs b/TaskTrackingSystem.WebApp/ApiClientService.cs new file mode 100644 index 0000000000000000000000000000000000000000..c0a1fb1080d321b3fe70ee3578bc8e8baae7fc26 --- /dev/null +++ b/TaskTrackingSystem.WebApp/ApiClientService.cs @@ -0,0 +1,81 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace TaskTrackingSystem.WebApp; + +/// +/// Scoped service that creates authorized HttpClient instances for the WebApi. +/// Inject this instead of IHttpClientFactory in Blazor components. +/// +public class ApiClientService +{ + private readonly IHttpClientFactory _factory; + private readonly UserSessionState _sessionState; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// The base URL of the WebApi (without trailing /api/). Used for direct file download links. + public string BaseUrl { get; } + + public ApiClientService( + IHttpClientFactory factory, + UserSessionState sessionState, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration) + { + _factory = factory; + _sessionState = sessionState; + _httpContextAccessor = httpContextAccessor; + + // Strip trailing /api/ so components can build their own paths + var rawUrl = configuration["WebApi:BaseUrl"] ?? "http://127.0.0.1:5001/api/"; + BaseUrl = rawUrl.TrimEnd('/').EndsWith("/api", StringComparison.OrdinalIgnoreCase) + ? rawUrl.TrimEnd('/')[..^4] // remove trailing /api + : rawUrl.TrimEnd('/'); + } + + public HttpClient CreateClient() + { + return CreateClient(null); + } + + public HttpClient CreateClient(ClaimsPrincipal? user) + { + var client = _factory.CreateClient("WebApi"); + var token = ResolveToken(user); + + if (!string.IsNullOrEmpty(token)) + { + _sessionState.Token = token; + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + } + + return client; + } + + public string? GetCurrentToken(ClaimsPrincipal? user = null) => ResolveToken(user); + + private string? ResolveToken(ClaimsPrincipal? user) + { + if (!string.IsNullOrEmpty(_sessionState.Token)) + { + return _sessionState.Token; + } + + var claimToken = user?.FindFirst("jwt_token")?.Value; + if (!string.IsNullOrEmpty(claimToken)) + { + return claimToken; + } + + var cachedToken = _sessionState.CachedUser?.FindFirst("jwt_token")?.Value; + if (!string.IsNullOrEmpty(cachedToken)) + { + return cachedToken; + } + + return _httpContextAccessor.HttpContext?.User.FindFirst("jwt_token")?.Value; + } +} diff --git a/TaskTrackingSystem.WebApp/AuthorizedHttpClientFactory.cs b/TaskTrackingSystem.WebApp/AuthorizedHttpClientFactory.cs new file mode 100644 index 0000000000000000000000000000000000000000..7f9f44799db4ab85fa86ceb118c42b1780ee7435 --- /dev/null +++ b/TaskTrackingSystem.WebApp/AuthorizedHttpClientFactory.cs @@ -0,0 +1,37 @@ +using System; +using System.Net.Http; + +namespace TaskTrackingSystem.WebApp; + +public class AuthorizedHttpClientFactory : IHttpClientFactory +{ + private readonly IHttpClientFactory _innerFactory; + private readonly UserSessionState _sessionState; + + public AuthorizedHttpClientFactory(IHttpClientFactory innerFactory, UserSessionState sessionState) + { + _innerFactory = innerFactory; + _sessionState = sessionState; + } + + public HttpClient CreateClient(string name) + { + var client = _innerFactory.CreateClient(name); + if (name == "WebApi") + { + try + { + var token = _sessionState.Token; + if (!string.IsNullOrEmpty(token)) + { + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + } + catch (Exception ex) + { + Console.WriteLine($"[AuthorizedHttpClientFactory] Error attaching bearer token: {ex.Message}"); + } + } + return client; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/App.razor b/TaskTrackingSystem.WebApp/Components/App.razor new file mode 100644 index 0000000000000000000000000000000000000000..2813ae5c332c7ca438724bce8fdae538a64930d1 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/App.razor @@ -0,0 +1,603 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TaskTrackingSystem.WebApp/Components/CustomAuthenticationStateProvider.cs b/TaskTrackingSystem.WebApp/Components/CustomAuthenticationStateProvider.cs new file mode 100644 index 0000000000000000000000000000000000000000..4f682247674a509d687f88518c4c27712bfeace6 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/CustomAuthenticationStateProvider.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Components.Authorization; +using System.Security.Claims; + +namespace TaskTrackingSystem.WebApp.Components; + +// Cookie auth with in-circuit cache so interactive Blazor pages keep the signed-in user. +public class CustomAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserSessionState _sessionState; + private static readonly AuthenticationState Anonymous = + new(new ClaimsPrincipal(new ClaimsIdentity())); + + public CustomAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, UserSessionState sessionState) + { + _httpContextAccessor = httpContextAccessor; + _sessionState = sessionState; + } + + public override Task GetAuthenticationStateAsync() + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated == true) + { + CacheUser(user); + return Task.FromResult(new AuthenticationState(user)); + } + + if (_sessionState.CachedUser?.Identity?.IsAuthenticated == true) + { + return Task.FromResult(new AuthenticationState(_sessionState.CachedUser)); + } + + return Task.FromResult(Anonymous); + } + + public void NotifyUserAuthenticationChanged() + { + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + private void CacheUser(ClaimsPrincipal user) + { + _sessionState.CachedUser = user; + + var token = user.FindFirst("jwt_token")?.Value; + if (!string.IsNullOrEmpty(token)) + { + _sessionState.Token = token; + } + + var roleId = user.FindFirst("role_id")?.Value; + if (!string.IsNullOrWhiteSpace(roleId)) + { + _sessionState.CachedAccessRoleId = $"id:{roleId}"; + return; + } + + var roleName = user.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value; + if (!string.IsNullOrWhiteSpace(roleName)) + { + _sessionState.CachedAccessRoleId = $"name:{roleName}"; + } + } +} + diff --git a/TaskTrackingSystem.WebApp/Components/Layout/EmptyLayout.razor b/TaskTrackingSystem.WebApp/Components/Layout/EmptyLayout.razor new file mode 100644 index 0000000000000000000000000000000000000000..4f3f76da5170603683fd00fff5bbfb3fe5dcc7d7 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/EmptyLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor b/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor new file mode 100644 index 0000000000000000000000000000000000000000..dc2c4a06013f193ab4fb05f11bd5eff8529ca148 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor @@ -0,0 +1,305 @@ +@inherits LayoutComponentBase +@implements IDisposable +@using System.Security.Claims +@inject TaskTrackingSystem.WebApp.Localization.UiLanguageService Language + +
+ + + + +
+
+
+ +
+

@CurrentPageTitle

+ +
+
+
+ + + + + + + + +
+
+ +
+ @if (isAuthorizedPage) + { + @Body + } + else + { +
+
+ +
+

@Language.Texts.AccessDenied

+

+ @Language.Texts.AccessDeniedDescription +

+ + + @Language.Texts.BackToDashboard + +
+ } +
+
+ + +
+ +@code { + [Inject] private NavigationManager Navigation { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + [Inject] private MenuAuthorizationService MenuAuthorization { get; set; } = default!; + [Inject] private Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider AuthStateProvider { get; set; } = default!; + + private bool isAuthorizedPage = true; + private string dashboardHref = "/dashboard"; + + private bool IsDashboardPage => + string.IsNullOrEmpty(Navigation.ToBaseRelativePath(Navigation.Uri)) || + Navigation.ToBaseRelativePath(Navigation.Uri).Split('?')[0].Trim('/') is "dashboard" or "Home" or ""; + + private string CurrentRoute => Navigation.ToBaseRelativePath(Navigation.Uri).Split('?')[0].Trim('/'); + + private string CurrentPageTitle + { + get + { + if (CurrentRoute.StartsWith("projects/", StringComparison.OrdinalIgnoreCase) && + CurrentRoute.EndsWith("/tasks", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.PageTitle("kanbanBoard", "Kanban Board"); + } + + if (CurrentRoute.StartsWith("tasks/", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.PageTitle("taskDetails", "Task Details"); + } + + return CurrentRoute switch + { + "" or "dashboard" or "Home" => AppLocalization.PageTitle("dashboard", "Dashboard"), + "dashboard/manager" => AppLocalization.PageTitle("managerDashboard", "Manager Dashboard"), + "dashboard/employee" => AppLocalization.PageTitle("employeeDashboard", "Employee Dashboard"), + "projects" or "projects/all" => AppLocalization.PageTitle("projects", "Projects"), + "projects/assign" => AppLocalization.PageTitle("projectAssignment", "Project Assignment"), + "tasks" or "tasks/all" => AppLocalization.PageTitle("tasks", "Tasks"), + "board" => AppLocalization.PageTitle("kanbanBoard", "Kanban Board"), + "tasks/assign" => AppLocalization.PageTitle("taskAssignment", "Task Assignment"), + "issues" or "issues/all" => AppLocalization.PageTitle("issues", "Issues"), + "issues/add" => AppLocalization.PageTitle("addIssue", "Add Issue"), + "reports/tasks" => AppLocalization.PageTitle("taskReport", "Task Report"), + "reports/time" => AppLocalization.PageTitle("timeTracking", "Time Tracking"), + "reports/projects" => AppLocalization.PageTitle("projectProgress", "Project Progress"), + "reports/overdue" => AppLocalization.PageTitle("overdueTasks", "Overdue Tasks"), + "reports/employees" => AppLocalization.PageTitle("employeeReport", "Employee Report"), + "roles" => AppLocalization.PageTitle("rolesAccess", "Roles & Access"), + "users" => AppLocalization.PageTitle("users", "Users"), + "audit-logs" => AppLocalization.PageTitle("auditLogs", "Audit Logs"), + _ => AppLocalization.Text("common.workspace", "Workspace") + }; + } + } + + private string CurrentPageDescription => CurrentPageTitle switch + { + var title when title == AppLocalization.PageTitle("dashboard", "Dashboard") => AppLocalization.PageDescription("dashboard", "A quick overview of work, risk, and progress."), + var title when title == AppLocalization.PageTitle("managerDashboard", "Manager Dashboard") => AppLocalization.PageDescription("managerDashboard", "Track projects, people, and delivery health."), + var title when title == AppLocalization.PageTitle("employeeDashboard", "Employee Dashboard") => AppLocalization.PageDescription("employeeDashboard", "Focus on your tasks, due dates, and progress."), + var title when title == AppLocalization.PageTitle("projects", "Projects") => AppLocalization.PageDescription("projects", "Manage project plans, timelines, and assignments."), + var title when title == AppLocalization.PageTitle("projectAssignment", "Project Assignment") => AppLocalization.PageDescription("projectAssignment", "Assign team members to the right projects."), + var title when title == AppLocalization.PageTitle("tasks", "Tasks") => AppLocalization.PageDescription("tasks", "Review, filter, and update work items."), + var title when title == AppLocalization.PageTitle("kanbanBoard", "Kanban Board") => AppLocalization.PageDescription("kanbanBoard", "Move work through status columns."), + var title when title == AppLocalization.PageTitle("taskAssignment", "Task Assignment") => AppLocalization.PageDescription("taskAssignment", "Assign tasks to project members."), + var title when title == AppLocalization.PageTitle("issues", "Issues") => AppLocalization.PageDescription("issues", "Track blockers, notes, and daily work progress."), + var title when title == AppLocalization.PageTitle("addIssue", "Add Issue") => AppLocalization.PageDescription("addIssue", "Record a new issue against a task."), + var title when title == AppLocalization.PageTitle("rolesAccess", "Roles & Access") => AppLocalization.PageDescription("rolesAccess", "Control user roles, menus, and permissions."), + var title when title == AppLocalization.PageTitle("users", "Users") => AppLocalization.PageDescription("users", "Manage people, status, and account details."), + var title when title == AppLocalization.PageTitle("auditLogs", "Audit Logs") => AppLocalization.PageDescription("auditLogs", "Review system activity and changes."), + var title when title.Contains(AppLocalization.Text("page.taskReport", "Task Report"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.taskReport.desc", "Analyze work data and export reports."), + var title when title.Contains(AppLocalization.Text("page.timeTracking", "Time Tracking"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.timeTracking.desc", "Review effort by task, project, and assignee."), + var title when title.Contains(AppLocalization.Text("page.projectProgress", "Project Progress"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.projectProgress.desc", "Monitor delivery health across active projects."), + var title when title.Contains(AppLocalization.Text("page.overdueTasks", "Overdue Tasks"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.overdueTasks.desc", "Focus on delayed work that needs attention."), + var title when title.Contains(AppLocalization.Text("page.employeeReport", "Employee Report"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.employeeReport.desc", "See how work is distributed across people."), + var title when title.Contains(AppLocalization.Text("page.issueReport", "Issue Report"), StringComparison.OrdinalIgnoreCase) => AppLocalization.Text("page.issueReport.desc", "Track daily execution, backlog health, and issue-level effort."), + _ => AppLocalization.Text("common.workspace", "Workspace") + }; + + protected override async Task OnInitializedAsync() + { + Language.Changed += OnLanguageChanged; + Navigation.LocationChanged += OnLocationChanged; + var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri); + await UpdateAuthorizationAsync(relativePath); + } + + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + { + UpdateAuthorization(); + } + + private void UpdateAuthorization() + { + var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri); + + _ = UpdateAuthorizationAsync(relativePath); + } + + private void OnLanguageChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + private async Task UpdateAuthorizationAsync(string relativePath) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated != true) + { + if (!MenuAuthorizationService.IsPublicRoute(relativePath)) + { + var returnPath = new Uri(Navigation.Uri).AbsolutePath; + Navigation.NavigateTo($"/login?ReturnUrl={Uri.EscapeDataString(returnPath)}"); + } + + isAuthorizedPage = MenuAuthorizationService.IsPublicRoute(relativePath); + await InvokeAsync(StateHasChanged); + return; + } + + var menus = await MenuAuthorization.GetUserMenusAsync(user); + dashboardHref = ResolveDashboardHref(menus); + var allowed = MenuAuthorization.IsRouteAllowed(user, relativePath); + isAuthorizedPage = allowed; + await InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + public void Dispose() + { + Navigation.LocationChanged -= OnLocationChanged; + Language.Changed -= OnLanguageChanged; + } + + private static string ResolveDashboardHref(IEnumerable menus) + { + foreach (var menu in menus) + { + var found = ResolveDashboardHref(menu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return "/dashboard"; + } + + private static string? ResolveDashboardHref(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu) + { + if (!string.IsNullOrWhiteSpace(menu.MenuUrl) && + menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase)) + { + return menu.MenuUrl; + } + + foreach (var subMenu in menu.SubMenus) + { + var found = ResolveDashboardHref(subMenu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return null; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css b/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000000000000000000000000000000000000..ae17f402ca6de92633a4cdd5c8e8bfbb04a6091f --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor.css @@ -0,0 +1,59 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + background-color: var(--app-bg); +} + +.sidebar { + background-color: var(--sidebar-bg); +} + +.top-row { + background-color: var(--surface); + border-bottom: 1px solid var(--border); + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + +.top-row ::deep a, +.top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + color: var(--text-muted); +} + +.top-row ::deep a:hover, +.top-row ::deep .btn-link:hover { + text-decoration: underline; + color: var(--text-primary); +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } +} + +#blazor-error-ui { + background: var(--status-danger-bg); + color: var(--status-danger-text); + bottom: 0; + position: fixed; + width: 100%; + z-index: 1000; +} diff --git a/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor new file mode 100644 index 0000000000000000000000000000000000000000..cbcc011cfe91cb33b4d460e05d22eedb0c0dd0ce --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor @@ -0,0 +1,279 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Routing +@using TaskTrackingSystem.Shared.Models.Menu +@using Microsoft.JSInterop +@implements IDisposable +@inject TaskTrackingSystem.WebApp.Localization.UiLanguageService Language +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager Navigation +@inject IJSRuntime JS + + + +@code { + private List menus = new(); + private string currentPath = string.Empty; + private HashSet expandedMenuGroups = new(); + + protected override async Task OnInitializedAsync() + { + Language.Changed += OnLanguageChanged; + currentPath = Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/'); + Navigation.LocationChanged += OnLocationChanged; + + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + var rawMenus = await MenuAuthorization.GetUserMenusAsync(user); + menus = ProcessMenus(rawMenus); + BuildUrlHierarchy(menus); + + foreach (var menu in menus) + { + if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus)) + { + expandedMenuGroups.Add(menu.MenuCode); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading dynamic menus: {ex.Message}"); + } + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + currentPath = Navigation.ToBaseRelativePath(e.Location).Trim('/'); + RefreshExpandedMenuGroups(); + _ = InvokeAsync(StateHasChanged); + } + + private void OnLanguageChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + private List ProcessMenus(List rawMenus) + { + return rawMenus + .Where(menu => !string.Equals(menu.MenuCode, "ROLE_LAYOUTS", StringComparison.OrdinalIgnoreCase) && + !string.Equals(menu.MenuCode, "DASHBOARD_WIDGETS", StringComparison.OrdinalIgnoreCase)) + .OrderBy(menu => string.Equals(menu.MenuCode, "DASHBOARD", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(menu => menu.OrderNo) + .ThenBy(menu => AppLocalization.Menu(menu.MenuCode, menu.MenuName)) + .ToList(); + } + + private async Task RefreshMenusAsync() + { + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + var freshMenus = await MenuAuthorization.GetUserMenusAsync(user); + var processedMenus = ProcessMenus(freshMenus); + + // Only update and re-render if the menu list actually changed. + if (processedMenus.Count != menus.Count || + processedMenus.Select(m => m.MenuCode).SequenceEqual(menus.Select(m => m.MenuCode)) == false) + { + menus = processedMenus; + BuildUrlHierarchy(menus); + expandedMenuGroups.Clear(); + foreach (var menu in menus) + { + if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus)) + { + expandedMenuGroups.Add(menu.MenuCode); + } + } + } + else + { + // Access items are the same - just refresh expanded state for the new route. + foreach (var menu in menus) + { + if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus)) + { + expandedMenuGroups.Add(menu.MenuCode); + } + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error refreshing menus on navigation: {ex.Message}"); + } + + StateHasChanged(); + } + + private void RefreshExpandedMenuGroups() + { + foreach (var menu in menus) + { + if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus)) + { + expandedMenuGroups.Add(menu.MenuCode); + } + } + } + + private void ToggleMenuGroup(string menuCode) + { + if (!expandedMenuGroups.Remove(menuCode)) + { + expandedMenuGroups.Add(menuCode); + } + } + + private bool IsMenuGroupExpanded(string menuCode) => expandedMenuGroups.Contains(menuCode); + + private bool IsMenuExpanded(string menuCode, IEnumerable subMenus) + { + return subMenus.Any(s => + !string.IsNullOrWhiteSpace(s.MenuUrl) && + currentPath.StartsWith(s.MenuUrl.Trim('/'), StringComparison.OrdinalIgnoreCase)); + } + + private static NavLinkMatch GetNavLinkMatch(string? menuUrl) + { + return string.IsNullOrWhiteSpace(menuUrl) || menuUrl.Trim('/') is "dashboard" or "" + ? NavLinkMatch.All + : NavLinkMatch.Prefix; + } + + private static void BuildUrlHierarchy(List menuList) + { + var toRemove = new List(); + + foreach (var parent in menuList) + { + if (string.IsNullOrWhiteSpace(parent.MenuUrl)) + { + continue; + } + + if (parent.SubMenus != null && parent.SubMenus.Count > 0) + { + continue; + } + + var parentPath = parent.MenuUrl.Trim('/'); + if (string.IsNullOrEmpty(parentPath)) + { + continue; + } + + var children = menuList + .Where(m => m != parent + && !string.IsNullOrWhiteSpace(m.MenuUrl) + && m.MenuUrl.Trim('/').StartsWith(parentPath + "/", StringComparison.OrdinalIgnoreCase)) + .OrderBy(m => m.OrderNo) + .ToList(); + + if (children.Count > 0) + { + parent.SubMenus = children; + toRemove.AddRange(children); + } + } + + foreach (var item in toRemove) + { + menuList.Remove(item); + } + } + + private static RenderFragment RenderIcon(string? icon) => builder => + { + builder.OpenElement(0, "i"); + builder.AddAttribute(1, "data-lucide", string.IsNullOrWhiteSpace(icon) ? "circle" : icon); + builder.AddAttribute(2, "class", "h-4 w-4 mr-3 text-slate-400 shrink-0"); + builder.CloseElement(); + }; + + private static RenderFragment RenderSmallIcon(string? icon) => builder => + { + builder.OpenElement(0, "i"); + builder.AddAttribute(1, "data-lucide", string.IsNullOrWhiteSpace(icon) ? "circle" : icon); + builder.AddAttribute(2, "class", "h-3.5 w-3.5 mr-3 text-slate-400 shrink-0"); + builder.CloseElement(); + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + try + { + await JS.InvokeVoidAsync("initIcons"); + } + catch + { + } + } + + public void Dispose() + { + Navigation.LocationChanged -= OnLocationChanged; + Language.Changed -= OnLanguageChanged; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.cs b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.cs new file mode 100644 index 0000000000000000000000000000000000000000..7b4e45cdc86c5ff28c95e11894999a1a86a0988a --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.cs @@ -0,0 +1,6 @@ +namespace TaskTrackingSystem.WebApp.Components.Layout +{ + public partial class NavMenu + { + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000000000000000000000000000000000000..77dc7f14559cbff7a7dc3437714cf77423ef586b --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor.css @@ -0,0 +1,82 @@ +::deep a.nav-link-item, +::deep button.nav-dropdown-toggle { + display: flex; + width: 100%; + align-items: center; + border: 1px solid transparent; + border-radius: var(--radius-lg, 0.5rem); + padding: 0.65rem 0.85rem; + color: var(--sidebar-muted, #CBD5E1); + font-size: 0.9rem; + font-weight: 650; + line-height: 1.2; + transition: + background-color 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} + +::deep button.nav-dropdown-toggle { + background: transparent; + cursor: pointer; +} + +::deep a.nav-link-item:hover, +::deep button.nav-dropdown-toggle:hover { + border-color: rgba(94, 234, 212, 0.14); + background-color: rgba(255, 255, 255, 0.065); + color: #FFFFFF; +} + +::deep a.nav-link-item.active { + border-color: rgba(94, 234, 212, 0.26) !important; + background-color: var(--sidebar-active, rgba(37, 99, 235, 0.18)) !important; + color: #FFFFFF !important; + box-shadow: inset 3px 0 0 var(--brand-primary); +} + +::deep button.nav-dropdown-toggle.expanded { + color: #FFFFFF; +} + +::deep a.nav-link-item i, +::deep button.nav-dropdown-toggle i { + color: currentColor !important; + opacity: 0.78; + transition: opacity 0.16s ease; +} + +::deep a.nav-link-item:hover i, +::deep a.nav-link-item.active i, +::deep button.nav-dropdown-toggle:hover i, +::deep button.nav-dropdown-toggle.expanded i { + opacity: 1; +} + +::deep button.nav-dropdown-toggle .nav-chevron { + margin-left: auto; + opacity: 0.65; + transition: transform 0.18s ease, opacity 0.18s ease; +} + +::deep button.nav-dropdown-toggle.expanded .nav-chevron { + transform: rotate(180deg); + opacity: 1; +} + +::deep .nav-dropdown-children { + margin: 0.2rem 0 0.45rem 1rem; + padding-left: 0.65rem; + border-left: 1px solid rgba(148, 163, 184, 0.22); +} + +::deep a.nav-child-item { + padding: 0.54rem 0.7rem; + font-size: 0.84rem; + font-weight: 620; +} + +::deep a.nav-link-item.border-t { + border-top-color: rgba(148, 163, 184, 0.18) !important; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Audit/AuditLogs.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Audit/AuditLogs.razor new file mode 100644 index 0000000000000000000000000000000000000000..3f07b999e18c94516833212c7d41de66869823c3 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Audit/AuditLogs.razor @@ -0,0 +1,229 @@ +@page "/audit-logs" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Models.AuditLog +@using TaskTrackingSystem.WebApp.Components.Partial +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider + +@AppLocalization.PageTitle("auditLogs", "Audit Logs") + +
+ +
+
+

@AppLocalization.PageTitle("auditLogs", "Audit Logs")

+

@AppLocalization.PageDescription("auditLogs", "Review system activity and changes.")

+
+
+ + +
+
+
+ +
+ + +
+
+ + @if (isLoading) + { + + } + else + { + +
+ + + + + + + + + + + + + + @if (PagedLogs.Any()) + { + @foreach (var entry in PagedLogs.Select((log, index) => (log, index))) + { + var log = entry.log; + + + + + + + + + + } + } + else + { + + } + +
No.@AppLocalization.Text("common.date", "Timestamp")@AppLocalization.Text("common.user", "User")@AppLocalization.Text("common.module", "Module")@AppLocalization.Text("common.actions", "Action")@AppLocalization.Text("common.description", "Description")IP Address
@((currentPage - 1) * pageSize + entry.index + 1) + @log.CreatedAt.ToString("dd-MM-yyyy HH:mm:ss") + +
+ + @(log.UserFullName.Length > 0 ? log.UserFullName[0].ToString().ToUpper() : "S") + +
+

@log.UserFullName

+

@@@log.Username

+
+
+
+ + @log.Module + + + + @log.Action + + + @log.Description + + @(string.IsNullOrWhiteSpace(log.IpAddress) ? "N/A" : log.IpAddress) +
+ + + +
+ } +
+ +@code { + private bool isLoading = true; + private List auditLogs = new(); + private string searchInput = ""; + + // Pagination state + private int currentPage = 1; + private int pageSize = 15; + private int totalCount = 0; + private int totalPages = 0; + private int TotalPages => totalPages; + private int TotalCount => totalCount; + private IEnumerable PagedLogs => auditLogs; + + protected override async Task OnInitializedAsync() + { + await LoadLogs(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadLogs() + { + isLoading = true; + var client = ApiClient.CreateClient(); + try + { + var url = BuildAuditLogsUrl(); + var result = await client.GetFromJsonAsync>(url, Serialization.CaseInsensitive); + auditLogs = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + } + catch (Exception ex) + { + Console.WriteLine($"Load audit logs error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private void ApplySearch() + { + currentPage = 1; + _ = LoadLogs(); + } + + private void ResetSearch() + { + searchInput = ""; + currentPage = 1; + _ = LoadLogs(); + } + + private void HandleKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + ApplySearch(); + } + } + + private string BuildAuditLogsUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + return $"AuditLog?{string.Join("&", query)}"; + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadLogs(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadLogs(); + } + + private string GetActionBadge(string action) + { + return action.ToLower() switch + { + "create" => "bg-emerald-50 text-emerald-700 border border-emerald-200", + "update" => "bg-amber-50 text-amber-700 border border-amber-200", + "delete" => "bg-rose-50 text-rose-700 border border-rose-200", + "assignaccess" => "bg-violet-50 text-violet-700 border border-violet-200", + _ => "bg-blue-50 text-blue-700 border border-blue-200" + }; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Login.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Login.razor new file mode 100644 index 0000000000000000000000000000000000000000..cb2e4f3d2db67136d54230702ec4c3782a21bede --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Login.razor @@ -0,0 +1,187 @@ +@page "/login" +@layout Layout.EmptyLayout +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject IJSRuntime JS + +@AppLocalization.Text("common.signIn", "Sign In") - Taskify + +
+
+
+
+ + + +
+

@AppLocalization.Text("common.signIn", "Sign In") - Taskify

+

@AppLocalization.PageDescription("dashboard", "Continue to your work dashboard.")

+
+ +
+ + + +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + @if (ShowUnavailableError) + { +
+

@AppLocalization.Text("result.apiUnavailable", "Unable to reach the API server. Start TaskTrackingSystem.WebApi and try again.")

+
+ } + else if (ShowError) + { +
+

@ResultMessages.InvalidCredentials

+
+ } + + @if (ShowLoggedOutMessage) + { +
+

@AppLocalization.Text("result.signedOut", "You have been signed out successfully.")

+
+ } + else if (ShowResetMessage) + { +
+

@AppLocalization.Text("result.passwordUpdated", "Password updated successfully. Please sign in with your new password.")

+
+ } + +

+ @AppLocalization.Text("auth.needAccount", "Need an account?") + @AppLocalization.Text("auth.register", "Register") +

+ +
+ +
+ +

+ @AppLocalization.Text("auth.resetPassword", "Reset password") +

+ +
+
+ +@code { + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } + + [SupplyParameterFromQuery(Name = "loggedOut")] + private string? LoggedOut { get; set; } + + [SupplyParameterFromQuery(Name = "reset")] + private string? Reset { get; set; } + + private bool ShowError => string.Equals(Error, "invalid", StringComparison.OrdinalIgnoreCase); + private bool ShowUnavailableError => string.Equals(Error, "unavailable", StringComparison.OrdinalIgnoreCase); + private bool ShowLoggedOutMessage => string.Equals(LoggedOut, "true", StringComparison.OrdinalIgnoreCase); + private bool ShowResetMessage => string.Equals(Reset, "true", StringComparison.OrdinalIgnoreCase); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + + if (firstRender) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated == true) + { + Navigation.NavigateTo(await ResolveLandingPageAsync(authState.User), forceLoad: true); + } + } + } + + private bool showPassword = false; + + private async Task TogglePassword() + { + showPassword = !showPassword; + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task ResolveLandingPageAsync(System.Security.Claims.ClaimsPrincipal user) + { + try + { + var menus = await MenuAuthorization.GetUserMenusAsync(user); + var landing = FindFirstDashboardMenu(menus); + if (!string.IsNullOrWhiteSpace(landing)) + { + return landing; + } + } + catch + { + } + + return "/dashboard"; + } + + private static string? FindFirstDashboardMenu(IEnumerable menus) + { + foreach (var menu in menus) + { + var found = FindFirstDashboardMenu(menu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return null; + } + + private static string? FindFirstDashboardMenu(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu) + { + if (!string.IsNullOrWhiteSpace(menu.MenuUrl) && + menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase)) + { + return menu.MenuUrl; + } + + foreach (var subMenu in menu.SubMenus) + { + var found = FindFirstDashboardMenu(subMenu); + if (!string.IsNullOrWhiteSpace(found)) + { + return found; + } + } + + return null; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Register.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Register.razor new file mode 100644 index 0000000000000000000000000000000000000000..8ae96d7f9a1b70246b2b7d4a20a926b099ac44e3 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Register.razor @@ -0,0 +1,98 @@ +@page "/register" +@using TaskTrackingSystem.WebApp.Components.Partial +@layout Layout.EmptyLayout +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] +@inject IJSRuntime JS + +Register - Taskify + +
+
+
+
+ + + +
+

Create your Taskify account

+

+ Already have access? + Sign in +

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + @if (!string.IsNullOrEmpty(Error)) + { +
+

@Error

+
+ } + +
+ +
+ +
+
+ +@code { + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } + + private bool showPassword = false; + + private async Task TogglePassword() + { + showPassword = !showPassword; + await JS.InvokeVoidAsync("initIcons"); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/ResetPassword.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/ResetPassword.razor new file mode 100644 index 0000000000000000000000000000000000000000..4853fdca6f1fd00ee884e88a673d6ca399c5dd72 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/ResetPassword.razor @@ -0,0 +1,73 @@ +@page "/reset-password" +@layout Layout.EmptyLayout +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] +@inject NavigationManager Navigation +@inject IJSRuntime JS + +@AppLocalization.Text("auth.resetPassword", "Reset Password") - Taskify + +
+
+
+
+ + + +
+

@AppLocalization.Text("auth.resetPassword", "Reset your password")

+

+ @AppLocalization.Text("auth.resetPasswordHelp", "Use the recovery code configured in the API to set a new password.") +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (ShowError) + { +
+

@ErrorMessage

+
+ } + +
+ @AppLocalization.Text("common.backToDashboard", "Back to sign in") + +
+ +
+
+ +@code { + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } + + private bool ShowError => !string.IsNullOrWhiteSpace(Error); + private string ErrorMessage => string.IsNullOrWhiteSpace(Error) ? AppLocalization.Text("auth.unableToResetPassword", "Unable to reset password.") : Error; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Common/Error.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Common/Error.razor new file mode 100644 index 0000000000000000000000000000000000000000..7474392d99a2549d01d3e8274468d56ffe178a89 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Common/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@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; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/AdminOverview.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/AdminOverview.razor new file mode 100644 index 0000000000000000000000000000000000000000..8a61c967b042ad28844af70ab40fa221c050145d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/AdminOverview.razor @@ -0,0 +1,18 @@ +@namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components + +
+ @foreach (var metric in Metrics) + { + + } +
+ +@code { + [Parameter] public IReadOnlyList Metrics { get; set; } = Array.Empty(); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/DashboardMetric.cs b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/DashboardMetric.cs new file mode 100644 index 0000000000000000000000000000000000000000..f57be09d72118233ebb061fb3a28dddf39a82b6d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/DashboardMetric.cs @@ -0,0 +1,9 @@ +namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components; + +public sealed record DashboardMetric( + string Icon, + string BadgeText, + string Value, + string Label, + string IconWrapClass, + string BadgeClass); diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/EmployeeOverview.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/EmployeeOverview.razor new file mode 100644 index 0000000000000000000000000000000000000000..f83b7f3ef1e97ce77f9e5cf4ab1f2108096ad47e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/EmployeeOverview.razor @@ -0,0 +1,17 @@ +@namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components + +
+ @foreach (var metric in Metrics) + { + + } +
+ +@code { + [Parameter] public IReadOnlyList Metrics { get; set; } = Array.Empty(); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/KpiCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/KpiCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..34d5ccb3763395dc9f923c1050dda6afe9f872fe --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/KpiCard.razor @@ -0,0 +1,23 @@ +
+
+
+ +
+ @if (!string.IsNullOrWhiteSpace(BadgeText)) + { + @BadgeText + } +
+

@Value

+

@Label

+
+ +@code { + [Parameter, EditorRequired] public string Icon { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Value { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter] public string? BadgeText { get; set; } + [Parameter] public string IconWrapClass { get; set; } = "rounded-lg bg-teal-700 p-3 text-white dark:bg-teal-600"; + [Parameter] public string BadgeClass { get; set; } = "text-sm font-semibold text-emerald-600 dark:text-emerald-300"; + [Parameter] public string CardClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-5 text-slate-900 shadow-sm shadow-slate-200/50 transition-transform duration-200 hover:-translate-y-1 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/ManagerOverview.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/ManagerOverview.razor new file mode 100644 index 0000000000000000000000000000000000000000..e7f4dbf1f0233a7ac7a63a3095c522cbbd69c09d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Components/ManagerOverview.razor @@ -0,0 +1,17 @@ +@namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components + +
+ @foreach (var metric in Metrics) + { + + } +
+ +@code { + [Parameter] public IReadOnlyList Metrics { get; set; } = Array.Empty(); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/EmployeeDashboard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/EmployeeDashboard.razor new file mode 100644 index 0000000000000000000000000000000000000000..0ae217bd7cece54f22db0482f0a79ebd99fa30ef --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/EmployeeDashboard.razor @@ -0,0 +1,519 @@ +@page "/dashboard/employee" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider + +Employee Dashboard + +
+
+
+
+
+

Team Member

+

My Work

+

+ Tasks, deadlines, and your progress in one place. +

+
+ + +
+
+ + @if (isLoading) + { +
+
+ + + + + Loading dashboard... +
+
+ } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { +
+ @errorMessage +
+ } + else + { + + +
+
+
+
+
+ + + +
+

In Progress

+

Tasks currently being worked on

+
+
+ @InProgressTasks.Count +
+ +
+ @if (InProgressTasks.Any()) + { + @foreach (var task in InProgressTasks) + { +
+ +
+
@task.Title
+
@GetProjectName(task.ProjectId)
+
+
+
@task.DueDate.ToString("yyyy-MM-dd")
+
@GetDueLabel(task)
+
+
+ } + } + else + { +
No tasks in progress right now.
+ } +
+
+ +
+
+
+ + + +
+

To Do & Upcoming

+

Tasks waiting for action or coming up soon

+
+
+ @TodoUpcomingTasks.Count +
+ +
+ @if (TodoUpcomingTasks.Any()) + { + @foreach (var task in TodoUpcomingTasks) + { +
+ +
+
@task.Title
+
@GetProjectName(task.ProjectId)
+
+
+
@task.DueDate.ToString("yyyy-MM-dd")
+
@GetDueLabel(task)
+
+
+ } + } + else + { +
No upcoming tasks found.
+ } +
+
+ +
+
+
+ + + +
+

Overdue

+

Tasks past their due date

+
+
+ @OverdueTasks.Count +
+ +
+ @if (OverdueTasks.Any()) + { + @foreach (var task in OverdueTasks) + { +
+ + + +
+
@task.Title
+
@GetProjectName(task.ProjectId)
+
+
+
@task.DueDate.ToString("yyyy-MM-dd")
+
@DaysOverdue(task)
+
+
+ } + } + else + { +
No overdue tasks. Nice work.
+ } +
+
+
+ +
+
+
+
+
+ +

@CalendarMonthLabel

+ +
+

@CalendarTodayLabel

+
+
+ +
+ @foreach (var day in WeekdayLabels) + { + @day + } +
+ +
+ @foreach (var cell in CalendarCells) + { + + } +
+
+ +
+
+

Related Tasks

+

Tasks marked on @SelectedDateLabel

+
+ +
+ @if (SelectedDateTasks.Any()) + { + @foreach (var task in SelectedDateTasks) + { +
+ +
+
@task.Title
+
@GetProjectName(task.ProjectId)
+
+
+
@task.DueDate.ToString("yyyy-MM-dd")
+
@GetDueLabel(task)
+
+
+ } + } + else + { +
No tasks are scheduled for this day.
+ } +
+
+
+
+ } +
+
+ +@code { + private bool isLoading = true; + private string? errorMessage; + private long currentUserId; + private List tasks = new(); + private List projects = new(); + private DateTime selectedDate = DateTime.Today; + + private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + private List CalendarCells => BuildCalendarCells(); + private List SelectedDateTasks => tasks + .Where(task => task.DueDate.Date == selectedDate.Date) + .OrderBy(task => task.StatusId) + .ThenBy(task => task.DueDate) + .ToList(); + + private string SelectedDateLabel => selectedDate.ToString("dddd, MMM d"); + private string CalendarMonthLabel => selectedDate.ToString("MMMM yyyy"); + private string CalendarTodayLabel => DateTime.Today.ToString("dddd, d"); + private string CurrentTimeLabel => DateTime.Now.ToString("h:mm tt"); + private string TimeZoneLabel + { + get + { + var offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now); + var sign = offset >= TimeSpan.Zero ? "+" : "-"; + var hours = Math.Abs(offset.Hours); + var minutes = Math.Abs(offset.Minutes); + return minutes == 0 ? $"GMT{sign}{hours}" : $"GMT{sign}{hours}:{minutes:00}"; + } + } + + private List InProgressTasks => tasks + .Where(task => task.StatusId == AppTaskStatus.InProgress) + .OrderBy(task => task.DueDate) + .ToList(); + + private List TodoUpcomingTasks => tasks + .Where(task => task.StatusId == AppTaskStatus.Todo || (task.StatusId != AppTaskStatus.Done && task.DueDate.Date >= DateTime.Today)) + .OrderBy(task => task.DueDate) + .ToList(); + + private List OverdueTasks => tasks + .Where(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done) + .OrderBy(task => task.DueDate) + .ToList(); + + private List SummaryMetrics => BuildSummaryMetrics(); + + protected override async Task OnInitializedAsync() + { + await LoadDashboardData(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadDashboardData() + { + isLoading = true; + errorMessage = null; + + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) + { + long.TryParse(idStr, out currentUserId); + } + + var client = ApiClient.CreateClient(); + var tasksResult = await client.GetFromJsonAsync>>($"User/{currentUserId}/tasks", Serialization.CaseInsensitive); + var projectsResult = await client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + + if (tasksResult?.IsSuccess == true && tasksResult.Value != null) + { + tasks = tasksResult.Value.ToList(); + } + else + { + tasks = new List(); + } + selectedDate = DateTime.Today; + + projects = projectsResult ?? new List(); + } + catch (Exception ex) + { + errorMessage = $"Failed to load dashboard data: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private void SelectDate(DateTime date) + { + selectedDate = date.Date; + } + + private void PrevMonth() + { + selectedDate = selectedDate.AddMonths(-1); + } + + private void NextMonth() + { + selectedDate = selectedDate.AddMonths(1); + } + + private List BuildSummaryMetrics() + { + var inProgress = tasks.Count(task => task.StatusId == AppTaskStatus.InProgress); + var todo = tasks.Count(task => task.StatusId == AppTaskStatus.Todo); + var dueSoon = tasks.Count(task => task.StatusId != AppTaskStatus.Done && task.DueDate.Date >= DateTime.Today && task.DueDate.Date <= DateTime.Today.AddDays(3)); + var completed = tasks.Count(task => task.StatusId == AppTaskStatus.Done); + var overdue = tasks.Count(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done); + + return new List + { + new("timer-reset", "active", inProgress.ToString(), "In Progress", "rounded-2xl bg-violet-700 p-3 text-white", "text-sm font-semibold text-violet-700"), + new("list-checks", "queued", todo.ToString(), "To Do", "rounded-2xl bg-cyan-600 p-3 text-white", "text-sm font-semibold text-cyan-600"), + new("calendar-clock", "soon", dueSoon.ToString(), "Due Soon", "rounded-2xl bg-amber-500 p-3 text-white", "text-sm font-semibold text-amber-600"), + new("circle-check-big", "done", completed.ToString(), "Completed", "rounded-2xl bg-emerald-600 p-3 text-white", "text-sm font-semibold text-emerald-600"), + new("triangle-alert", "late", overdue.ToString(), "Overdue", "rounded-2xl bg-rose-600 p-3 text-white", "text-sm font-semibold text-rose-600") + }; + } + + private List BuildCalendarCells() + { + var firstOfMonth = new DateTime(selectedDate.Year, selectedDate.Month, 1); + var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek); + var end = start.AddDays(42); + + var dateMap = tasks + .Where(task => task.DueDate.Date >= start.Date && task.DueDate.Date < end.Date) + .GroupBy(task => task.DueDate.Date) + .ToDictionary(group => group.Key, group => group.ToList()); + + var cells = new List(); + for (var i = 0; i < 42; i++) + { + var date = start.AddDays(i); + dateMap.TryGetValue(date.Date, out var dayTasks); + dayTasks ??= new List(); + + cells.Add(new CalendarCellViewModel( + date, + date.Month == selectedDate.Month, + date.Date == selectedDate.Date, + dayTasks.Select(task => GetStatusColor(task.StatusId)).ToList(), + dayTasks.Count)); + } + + return cells; + } + + private string GetProjectName(long projectId) + { + return projects.FirstOrDefault(project => project.Id == projectId)?.Name ?? $"Project #{projectId}"; + } + + private string GetStatusColor(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "#22d3ee", + AppTaskStatus.InProgress => "#3b82f6", + AppTaskStatus.Done => "#10b981", + _ => "#94a3b8" + }; + + private string GetCalendarCellClasses(CalendarCellViewModel cell) + { + var classes = new List + { + "min-h-[52px] rounded-2xl border p-2 text-left transition-colors" + }; + + if (cell.IsSelected) + { + classes.Add("border-violet-500 bg-violet-600"); + } + else if (cell.IsCurrentMonth) + { + classes.Add("border-slate-200 bg-white hover:border-violet-200 hover:bg-slate-50"); + } + else + { + classes.Add("border-slate-100 bg-slate-50 text-slate-400"); + } + + return string.Join(" ", classes); + } + + private string GetDueLabel(TaskDto task) + { + var days = (task.DueDate.Date - DateTime.Today).Days; + + if (task.StatusId == AppTaskStatus.Done) + { + return "Done"; + } + + if (days < 0) + { + return $"{Math.Abs(days)}d ago"; + } + + if (days == 0) + { + return "Today"; + } + + return $"{days}d left"; + } + + private string GetDueTextClass(TaskDto task) + { + var days = (task.DueDate.Date - DateTime.Today).Days; + + if (task.StatusId == AppTaskStatus.Done) + { + return "text-emerald-600"; + } + + if (days < 0) + { + return "text-rose-600"; + } + + if (days <= 3) + { + return "text-amber-500"; + } + + return "text-sky-600"; + } + + private string DaysOverdue(TaskDto task) + { + var days = (DateTime.Today - task.DueDate.Date).Days; + return days <= 0 ? "Due today" : $"{days}d ago"; + } + + private sealed record CalendarCellViewModel( + DateTime Date, + bool IsCurrentMonth, + bool IsSelected, + List TaskDots, + int TaskCount); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Home.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Home.razor new file mode 100644 index 0000000000000000000000000000000000000000..e905e724548d44517abfc2bb7a45185e3f4a8999 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Home.razor @@ -0,0 +1,1004 @@ +@page "/" +@page "/Home" +@page "/dashboard" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.Shared.Models.Dashboard +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager NavigationManager + +Dashboard + +
+
+
+
+

Overview

+

Welcome back, @currentUserFullName

+

+ Projects, users, and team progress at a glance. +

+
+
+ @if (canCreateTask) + { + + + New Task + + } + + + Reports + +
+
+
+ + + +
+
+
+
+

Project Activity

+

Active vs completed tasks per month.

+
+
@ActivityRangeLabel
+
+ +
+
+ + + + + + + + + @for (var i = 0; i < 5; i++) + { + var y = 60 + (i * 55); + + } + + @for (var i = 0; i < 6; i++) + { + var x = 60 + (i * 125); + + } + + + + + @for (var i = 0; i < ActivityMonths.Count; i++) + { + var index = i; + var point = ActivityPoints[i]; + + } + + + @if (hoveredActivity is not null) + { +
+
@hoveredActivity.Value.Label
+
Completed: @hoveredActivity.Value.Completed
+
Active: @hoveredActivity.Value.Active
+
+ } +
+ +
+ @foreach (var label in ActivityLabels) + { + @label + } +
+
+
+ + + + + + +
+ +
+
+
+
+

All Projects

+

A quick snapshot of the current project list.

+
+ + View all + +
+ +
+ @foreach (var project in ProjectListItems) + { + var progress = GetProjectProgress(project.Id); +
+
+
+
+

@project.Name

+ @GetProjectBadgeLabel(project) +
+

@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)

+
+
+
@progress.CompletedTasksCount/@progress.TotalTasksCount tasks
+
@progress.CompletionPercentage%
+
+
+ +
+
+
+
+ } +
+
+ +
+
+
+

Calendar

+

Today and upcoming project starts.

+
+
+
@CalendarMonthLabel
+
+
+ +
+ @foreach (var day in WeekdayLabels) + { + @day + } +
+ +
+ @foreach (var cell in CalendarCells) + { +
+
+
@cell.Date.Day
+
+ @foreach (var dot in cell.ProjectDots) + { + + } +
+
+
+ } +
+ +
+
Events
+ @foreach (var item in UpcomingProjectStarts) + { +
+ +
+
@item.Name
+
@item.DateLabel
+
+
+ } +
+
+
+ +
+
+
+

Project Timeline

+

Browse every project in a gantt view and adjust the month window below.

+
+ + View all + +
+ +
+
+ + +
+
+ + +
+
+ @TimelineRangeLabel +
+
+ + @if (TimelineProjects.Count > 0) + { +
+
+
+ } + else + { +
No projects to display.
+ } +
+
+ +@code { + private DashboardSummaryDto summary = new(); + private List taskStatuses = new(); + private List projectProgress = new(); + private List taskList = new(); + private List usersList = new(); + private List projectsList = new(); + private string currentUserName = "User"; + private string currentUserFullName = "User"; + private bool canCreateTask = false; + private string timelineStartMonthValue = string.Empty; + private string timelineEndMonthValue = string.Empty; + + private bool _dataLoaded = false; + private bool _shouldRenderCharts = false; + + private int? hoveredActivityIndex; + private int? hoveredSliceIndex; + + private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + private List ActivityMonths => BuildActivityMonths(); + private List ActivityLabels => ActivityMonths.Select(month => month.Label).ToList(); + private string ActivityRangeLabel => $"{ActivityMonths.First().Label} - {ActivityMonths.Last().Label}"; + private string ActivityLinePath => BuildActivityPath(ActivityMonths); + private string ActivityAreaPath => BuildActivityAreaPath(ActivityMonths); + private List ActivityPoints => BuildActivityPoints(); + private List DonutSlices => BuildDonutSlices(); + private List DistributionLegend => BuildDistributionLegend(); + private List DistributionChartItems => DistributionLegend + .Select(item => new ChartDataPoint(item.Label, item.Count, item.Color)) + .ToList(); + private int TotalTasksCount => taskList.Count; + private int ActiveTasksCount => taskList.Count(t => t.StatusId != AppTaskStatus.Done); + private int CompletedTasksCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done); + private int CompletionRate => TotalTasksCount > 0 ? (int)Math.Round((double)CompletedTasksCount / TotalTasksCount * 100) : 0; + private string UsersDeltaText => BuildDeltaText(GetUsersThisMonth() - GetUsersPreviousMonth()); + private string ProjectsDeltaText => BuildDeltaText(GetProjectsThisMonth() - GetProjectsPreviousMonth()); + private string TasksDeltaText => BuildDeltaText(GetActiveTasksThisMonth() - GetActiveTasksPreviousMonth()); + private string CompletionDeltaText => BuildCompletionDeltaText(); + private List SummaryMetrics => new() + { + new("users", UsersDeltaText, summary.TotalUsers.ToString(), "Total users", "flex h-12 w-12 items-center justify-center rounded-lg bg-teal-700 text-white dark:bg-teal-600", "text-sm font-semibold text-emerald-600 dark:text-emerald-300"), + new("folder-kanban", ProjectsDeltaText, projectsList.Count.ToString(), "Total projects", "flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-500 text-white", "text-sm font-semibold text-emerald-500"), + new("check-square", TasksDeltaText, ActiveTasksCount.ToString(), "Active tasks", "flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-white", "text-sm font-semibold text-emerald-500"), + new("trending-up", CompletionDeltaText, $"{CompletionRate}%", "Completion rate", "flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-white", "text-sm font-semibold text-emerald-500") + }; + private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy"); + private List CalendarCells => BuildCalendarCells(); + private List UpcomingProjectStarts => BuildUpcomingProjectStarts(); + private List FeaturedProjects => TimelineProjects.Take(5).ToList(); + private List ProjectListItems => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); + private List TimelineProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); + private List TimelineMonths => BuildTimelineMonths(); + private string TimelineRangeLabel => TryGetTimelineRange(out var range) ? $"{range.Start:MMM yyyy} - {range.End:MMM yyyy}" : string.Empty; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + var codes = await MenuAuthorization.GetCurrentAccessCodesAsync(user); + + if (codes.Contains("Projects_Create") && !codes.Contains("Roles_List")) + { + NavigationManager.NavigateTo("/dashboard/manager"); + return; + } + else if (!codes.Contains("Projects_Create") && !codes.Contains("Roles_List")) + { + NavigationManager.NavigateTo("/dashboard/employee"); + return; + } + + canCreateTask = codes.Contains("Tasks_Create"); + await LoadDashboardData(); + } + + private async Task LoadDashboardData() + { + var client = ApiClient.CreateClient(); + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + currentUserName = user.Identity?.Name ?? "User"; + + var summaryResult = await client.GetFromJsonAsync>("Dashboard/summary", Serialization.CaseInsensitive); + if (summaryResult?.IsSuccess == true && summaryResult.Value != null) + { + summary = summaryResult.Value; + } + + var tasksOverviewResult = await client.GetFromJsonAsync>>("Dashboard/tasks-overview", Serialization.CaseInsensitive); + if (tasksOverviewResult?.IsSuccess == true && tasksOverviewResult.Value != null) + { + taskStatuses = tasksOverviewResult.Value; + } + + var progressResult = await client.GetFromJsonAsync>>("Dashboard/project-progress", Serialization.CaseInsensitive); + if (progressResult?.IsSuccess == true && progressResult.Value != null) + { + projectProgress = progressResult.Value; + } + + taskList = await client.GetFromJsonAsync>("Task", Serialization.CaseInsensitive) ?? new(); + usersList = await client.GetFromJsonAsync>("User", Serialization.CaseInsensitive) ?? new(); + projectsList = await client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive) ?? new(); + InitializeTimelineRange(); + + currentUserFullName = ResolveCurrentUserFullName(); + _dataLoaded = true; + _shouldRenderCharts = true; + StateHasChanged(); + } + catch (Exception ex) + { + Console.WriteLine($"Dashboard load error: {ex.Message}"); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + if (_shouldRenderCharts && _dataLoaded) + { + _shouldRenderCharts = false; + await RenderChartsAsync(); + } + } + + private async Task RenderChartsAsync() + { + try + { + var ganttData = TimelineProjects.Select(p => { + var progress = GetProjectProgress(p.Id); + return new { + id = p.Id.ToString(), + name = p.Name, + start = new DateTimeOffset(DateTime.SpecifyKind(p.StartDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(), + end = new DateTimeOffset(DateTime.SpecifyKind(p.EndDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(), + completed = progress.CompletionPercentage / 100.0, + color = GetProjectColor(p.Id) + }; + }).ToList(); + + if (ganttData.Count > 0 && TryGetTimelineRange(out var timelineRange)) + { + await JS.InvokeVoidAsync("renderGanttChart", "project-timeline-chart", ganttData, new + { + height = 420, + minWidth = Math.Max(900, GetTimelineMonthSpan(timelineRange.Start, timelineRange.End) * 110), + xAxisMin = ToUnixMilliseconds(timelineRange.Start), + xAxisMax = ToUnixMilliseconds(timelineRange.End) + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error rendering Highcharts: {ex.Message}"); + } + } + + private string ResolveCurrentUserFullName() + { + var user = usersList.FirstOrDefault(u => string.Equals(u.Username, currentUserName, StringComparison.OrdinalIgnoreCase)); + if (user != null) + { + return $"{user.FirstName} {user.LastName}".Trim(); + } + + return currentUserName; + } + + private List BuildActivityMonths() + { + var now = DateTime.Today; + var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5); + var months = new List(); + + for (var i = 0; i < 6; i++) + { + var monthStart = start.AddMonths(i); + var nextMonth = monthStart.AddMonths(1); + var active = taskList.Count(task => + task.CreatedAt >= monthStart && + task.CreatedAt < nextMonth && + task.StatusId != AppTaskStatus.Done); + + var completed = taskList.Count(task => + task.CompletedAt.HasValue && + task.CompletedAt.Value >= monthStart && + task.CompletedAt.Value < nextMonth); + + months.Add(new ActivityMonthStat(GetMonthLabel(monthStart), active, completed)); + } + + return months; + } + + private string BuildActivityPath(IReadOnlyList months) + { + if (months.Count == 0) + { + return string.Empty; + } + + const double left = 48; + const double right = 730; + const double top = 48; + const double bottom = 280; + const double maxValue = 20; + + double X(int i) => left + (i * ((right - left) / (months.Count - 1))); + double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top); + + var points = new List(); + for (var i = 0; i < months.Count; i++) + { + points.Add($"{X(i):0},{Y(months[i].Active):0}"); + } + + var path = $"M {points[0]}"; + for (var i = 1; i < points.Count; i++) + { + path += $" L {points[i]}"; + } + + return path; + } + + private string BuildActivityAreaPath(IReadOnlyList months) + { + if (months.Count == 0) + { + return string.Empty; + } + + const double left = 48; + const double right = 730; + const double top = 48; + const double bottom = 280; + const double maxValue = 20; + + double X(int i) => left + (i * ((right - left) / (months.Count - 1))); + double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top); + + var points = new List(); + for (var i = 0; i < months.Count; i++) + { + points.Add($"{X(i):0},{Y(months[i].Active):0}"); + } + + var path = $"M {points[0]}"; + for (var i = 1; i < points.Count; i++) + { + path += $" L {points[i]}"; + } + + return $"{path} L {X(months.Count - 1):0},{bottom:0} L {X(0):0},{bottom:0} Z"; + } + + private List BuildActivityPoints() + { + var months = ActivityMonths; + var points = new List(); + if (months.Count == 0) + { + return points; + } + + const double left = 48; + const double right = 730; + const double top = 48; + const double bottom = 280; + var maxValue = Math.Max(months.Max(m => m.Active), 1); + + double X(int i) => left + (i * ((right - left) / (months.Count - 1))); + double Y(double value) => bottom - Math.Min(value, maxValue) / (double)maxValue * (bottom - top); + + for (var i = 0; i < months.Count; i++) + { + points.Add(new ActivityPoint(months[i].Label, X(i), Y(months[i].Active), months[i].Active, months[i].Completed)); + } + + return points; + } + + private List BuildDonutSlices() + { + // 1. Calculate counts entirely from the same taskList to fix data mismatches + var todoCount = taskList.Count(t => t.StatusId == AppTaskStatus.Todo); + var inProgressCount = taskList.Count(t => t.StatusId == AppTaskStatus.InProgress); + var overdueCount = taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done); + + var rawCounts = new[] + { + CompletedTasksCount, // Already calculated from taskList + inProgressCount, + todoCount, + overdueCount + }; + + var colors = new[] { "#0f766e", "#38bdf8", "#94a3b8", "#dc2626" }; + + // 2. Adjust total so it maps 100% cleanly to the math displayed + var total = Math.Max(TotalTasksCount, 1); + var circumference = 2 * Math.PI * 74; // 2πr for r=74 + var slices = new List(); + var cumulativeArc = 0.0; + + for (var i = 0; i < rawCounts.Length; i++) + { + if (rawCounts[i] <= 0) + { + // Arc length 0 with a full-circumference gap → invisible. + // Avoids stroke-linecap="round" rendering a phantom dot. + slices.Add(new DistributionSlice(GetDistributionLabel(i), 0, colors[i], $"0 {circumference:0.##}", 0)); + continue; + } + + var fraction = rawCounts[i] / (double)total; + var arcLength = circumference * fraction; + + // dasharray: "{draw} {gap}" where gap = circumference - arcLength so the + // pattern period equals exactly one full revolution. This prevents any + // slice from bleeding into the next revolution of the ring. + var dash = $"{arcLength:0.##} {(circumference - arcLength):0.##}"; + + // dashOffset = -cumulativeArc: + // A negative offset shifts the start of the dash pattern forward along the + // path by cumulativeArc pixels, so this slice begins exactly where the + // previous slice ended. (The circle starts at 12 o'clock via rotate=-90.) + var dashOffset = -cumulativeArc; + + slices.Add(new DistributionSlice(GetDistributionLabel(i), rawCounts[i], colors[i], dash, dashOffset)); + cumulativeArc += arcLength; + } + + return slices; + } + + private static string GetDistributionLabel(int index) => index switch + { + 0 => "Completed", + 1 => "In Progress", + 2 => "To Do", + _ => "Overdue" + }; + + private List BuildDistributionLegend() + { + return DonutSlices.Select(slice => new DistributionLegendItem(slice.Label, slice.Count, slice.Color)).ToList(); + } + + private List BuildCalendarCells() + { + var today = DateTime.Today; + var firstOfMonth = new DateTime(today.Year, today.Month, 1); + var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek); + var projectStarts = projectsList + .Select(project => new { project.StartDate, Color = GetProjectColor(project.Id) }) + .Where(item => item.StartDate.Date >= start.Date && item.StartDate.Date < start.AddDays(42)) + .ToList(); + + var cells = new List(); + for (var i = 0; i < 42; i++) + { + var date = start.AddDays(i); + var markers = projectStarts + .Where(item => item.StartDate.Date == date.Date) + .Select(item => item.Color) + .ToList(); + + cells.Add(new CalendarCell(date, date.Month == today.Month, date.Date == today.Date, markers)); + } + + return cells; + } + + private List BuildUpcomingProjectStarts() + { + return projectsList + .Where(project => project.StartDate.Date >= DateTime.Today) + .OrderBy(project => project.StartDate) + .Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id))) + .ToList(); + } + + private List BuildTimelineMonths() + { + var featured = FeaturedProjects; + if (featured.Count == 0) + { + return new List(); + } + + var start = new DateTime(featured.Min(p => p.StartDate).Year, featured.Min(p => p.StartDate).Month, 1); + var end = new DateTime(featured.Max(p => p.EndDate).Year, featured.Max(p => p.EndDate).Month, 1); + + var months = new List(); + var current = start; + while (current <= end) + { + months.Add(current); + current = current.AddMonths(1); + } + + return months; + } + + private List BuildTimelineRows() + { + var months = TimelineMonths; + if (months.Count == 0) + { + return new List(); + } + + var first = months.First(); + var n = months.Count; // total columns — each flex-1 month slot = 1/n of bar width + + return FeaturedProjects.Select(project => + { + var startIndex = ((project.StartDate.Year - first.Year) * 12) + project.StartDate.Month - first.Month; + var endIndex = ((project.EndDate.Year - first.Year) * 12) + project.EndDate.Month - first.Month; + startIndex = Math.Clamp(startIndex, 0, n - 1); + endIndex = Math.Clamp(endIndex, startIndex, n - 1); + + // Use n as divisor so positions match the flex-1 month columns. + var left = (startIndex / (double)n) * 100; + var width = ((endIndex - startIndex + 1) / (double)n) * 100; + width = Math.Max(2, width); + + var progress = GetProjectProgress(project.Id); + + return new TimelineRow( + project.Name, + $"{DisplayFormats.Date(project.StartDate)} – {DisplayFormats.Date(project.EndDate)}", + GetProjectColor(project.Id), + left, + width, + progress.CompletionPercentage); + }).ToList(); + } + + private string GetTimelineTodayMarkerLeft() + { + var months = TimelineMonths; + if (months.Count == 0) return "0%"; + + var first = months.First(); + var n = months.Count; + var monthIndex = ((DateTime.Today.Year - first.Year) * 12) + DateTime.Today.Month - first.Month; + + // Clamp to visible range; if today is outside the timeline keep it at the edge. + monthIndex = Math.Clamp(monthIndex, 0, n - 1); + + // Add fractional day-of-month so the line sits at the correct intra-month position. + var dayFraction = (DateTime.Today.Day - 1.0) / DateTime.DaysInMonth(DateTime.Today.Year, DateTime.Today.Month); + return $"{((monthIndex + dayFraction) / n * 100):0.##}%"; + } + + private void InitializeTimelineRange() + { + if (TimelineProjects.Count == 0) + { + var currentMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + timelineStartMonthValue = currentMonth.ToString("yyyy-MM"); + timelineEndMonthValue = currentMonth.ToString("yyyy-MM"); + return; + } + + var start = new DateTime(TimelineProjects.Min(p => p.StartDate).Year, TimelineProjects.Min(p => p.StartDate).Month, 1); + var end = new DateTime(TimelineProjects.Max(p => p.EndDate).Year, TimelineProjects.Max(p => p.EndDate).Month, 1); + timelineStartMonthValue = start.ToString("yyyy-MM"); + timelineEndMonthValue = end.ToString("yyyy-MM"); + } + + private Task OnTimelineStartMonthChanged(ChangeEventArgs args) + { + timelineStartMonthValue = args.Value?.ToString() ?? timelineStartMonthValue; + NormalizeTimelineRange(); + _shouldRenderCharts = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnTimelineEndMonthChanged(ChangeEventArgs args) + { + timelineEndMonthValue = args.Value?.ToString() ?? timelineEndMonthValue; + NormalizeTimelineRange(); + _shouldRenderCharts = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private void NormalizeTimelineRange() + { + if (!TryParseMonthValue(timelineStartMonthValue, out var start) || + !TryParseMonthValue(timelineEndMonthValue, out var end)) + { + return; + } + + if (start > end) + { + (start, end) = (end, start); + } + + timelineStartMonthValue = start.ToString("yyyy-MM"); + timelineEndMonthValue = end.ToString("yyyy-MM"); + } + + private bool TryGetTimelineRange(out (DateTime Start, DateTime End) range) + { + if (!TryParseMonthValue(timelineStartMonthValue, out var start) || + !TryParseMonthValue(timelineEndMonthValue, out var end)) + { + range = default; + return false; + } + + if (start > end) + { + (start, end) = (end, start); + } + + var startOfMonth = new DateTime(start.Year, start.Month, 1); + var endOfMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month)); + range = (startOfMonth, endOfMonth); + return true; + } + + private static bool TryParseMonthValue(string? value, out DateTime month) + { + month = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return DateTime.TryParseExact($"{value}-01", "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out month); + } + + private static long ToUnixMilliseconds(DateTime value) + { + var utc = DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + return new DateTimeOffset(utc).ToUnixTimeMilliseconds(); + } + + private static int GetTimelineMonthSpan(DateTime start, DateTime end) + { + var span = ((end.Year - start.Year) * 12) + end.Month - start.Month + 1; + return Math.Max(span, 1); + } + + private ProjectProgressDto GetProjectProgress(long projectId) + { + return projectProgress.FirstOrDefault(item => item.ProjectId == projectId) + ?? new ProjectProgressDto + { + ProjectId = projectId, + ProjectName = string.Empty, + TotalTasksCount = taskList.Count(task => task.ProjectId == projectId), + CompletedTasksCount = taskList.Count(task => task.ProjectId == projectId && task.StatusId == AppTaskStatus.Done), + CompletionPercentage = 0 + }; + } + + private string GetProjectBadgeLabel(ProjectDto project) + { + return project.StartDate.Date > DateTime.Today ? "Upcoming" : "Active"; + } + + private string GetProjectBadgeClasses(ProjectDto project) + { + return project.StartDate.Date > DateTime.Today + ? "rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700" + : "rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700"; + } + + private string GetProjectColor(long projectId) + { + var palette = new[] + { + "#0f766e", + "#38bdf8", + "#10b981", + "#b45309", + "#64748b" + }; + + var index = (int)(projectId % palette.Length); + return palette[index]; + } + + private string GetCalendarCellClasses(CalendarCell cell) + { + var classes = new List + { + "min-h-[62px] rounded-2xl border p-2" + }; + + if (cell.IsToday) + { + classes.Add("border-violet-200 bg-violet-50"); + } + else if (cell.IsCurrentMonth) + { + classes.Add("border-slate-200 bg-white"); + } + else + { + classes.Add("border-slate-100 bg-slate-50/70"); + } + + return string.Join(' ', classes); + } + + private void SetHoveredActivity(int index) + { + hoveredActivityIndex = index; + } + + private void ClearHoveredActivity() + { + hoveredActivityIndex = null; + } + + private void SetHoveredSlice(int index) + { + hoveredSliceIndex = index; + } + + private void ClearHoveredSlice() + { + hoveredSliceIndex = null; + } + + private ActivityMonthStat? hoveredActivity => + hoveredActivityIndex.HasValue && hoveredActivityIndex.Value < ActivityMonths.Count + ? ActivityMonths[hoveredActivityIndex.Value] + : null; + + private DistributionSlice? hoveredSlice => + hoveredSliceIndex.HasValue && hoveredSliceIndex.Value < DonutSlices.Count + ? DonutSlices[hoveredSliceIndex.Value] + : null; + + private string HoveredActivityStyle + { + get + { + if (!hoveredActivityIndex.HasValue || hoveredActivityIndex.Value >= ActivityPoints.Count) + { + return string.Empty; + } + + var point = ActivityPoints[hoveredActivityIndex.Value]; + return $"left: {point.X / 760d * 100:0.##}%; top: {point.Y / 340d * 100:0.##}%; transform: translate(-50%, -110%);"; + } + } + + private string BuildDeltaText(int delta) + { + if (delta > 0) + { + return $"+{delta} this month"; + } + + if (delta < 0) + { + return $"{delta} this month"; + } + + return "0 this month"; + } + + private string BuildCompletionDeltaText() + { + var current = GetCompletionRateForMonth(DateTime.Today); + var previous = GetCompletionRateForMonth(DateTime.Today.AddMonths(-1)); + var delta = current - previous; + + if (delta > 0) + { + return $"+{delta}% vs last mo"; + } + + if (delta < 0) + { + return $"{delta}% vs last mo"; + } + + return "0% vs last mo"; + } + + private int GetUsersThisMonth() => + usersList.Count(user => user.CreatedAt.Year == DateTime.Today.Year && user.CreatedAt.Month == DateTime.Today.Month); + + private int GetUsersPreviousMonth() + { + var previous = DateTime.Today.AddMonths(-1); + return usersList.Count(user => user.CreatedAt.Year == previous.Year && user.CreatedAt.Month == previous.Month); + } + + private int GetProjectsThisMonth() => + projectsList.Count(project => project.CreatedAt.Year == DateTime.Today.Year && project.CreatedAt.Month == DateTime.Today.Month); + + private int GetProjectsPreviousMonth() + { + var previous = DateTime.Today.AddMonths(-1); + return projectsList.Count(project => project.CreatedAt.Year == previous.Year && project.CreatedAt.Month == previous.Month); + } + + private int GetActiveTasksThisMonth() => + taskList.Count(task => task.CreatedAt.Year == DateTime.Today.Year && task.CreatedAt.Month == DateTime.Today.Month && task.StatusId != AppTaskStatus.Done); + + private int GetActiveTasksPreviousMonth() + { + var previous = DateTime.Today.AddMonths(-1); + return taskList.Count(task => task.CreatedAt.Year == previous.Year && task.CreatedAt.Month == previous.Month && task.StatusId != AppTaskStatus.Done); + } + + private int GetCompletionRateForMonth(DateTime date) + { + var monthTasks = taskList.Where(task => task.CreatedAt.Year == date.Year && task.CreatedAt.Month == date.Month).ToList(); + if (monthTasks.Count == 0) + { + return 0; + } + + return (int)Math.Round(monthTasks.Count(task => task.StatusId == AppTaskStatus.Done) / (double)monthTasks.Count * 100); + } + + private string GetMonthLabel(DateTime date) => date.ToString("MMM"); + + private readonly record struct ActivityMonthStat(string Label, int Active, int Completed); + private readonly record struct ActivityPoint(string Label, double X, double Y, int Active, int Completed); + private readonly record struct DistributionSlice(string Label, int Count, string Color, string DashArray, double DashOffset); + private readonly record struct DistributionLegendItem(string Label, int Count, string Color); + private readonly record struct CalendarCell(DateTime Date, bool IsCurrentMonth, bool IsToday, List ProjectDots); + private readonly record struct CalendarEventItem(string Name, string DateLabel, string Color); + private readonly record struct TimelineRow(string Name, string DateLabel, string Color, double Left, double Width, double CompletionPercentage); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/ManagerDashboard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/ManagerDashboard.razor new file mode 100644 index 0000000000000000000000000000000000000000..ec8c59f45aec9b2b3efe05b3ce0e6dd7fa9b9dcf --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/ManagerDashboard.razor @@ -0,0 +1,702 @@ +@page "/dashboard/manager" +@using Microsoft.AspNetCore.Authorization +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider + +Manager Dashboard + +
+
+
+
+
+

Team View

+

My Projects

+

+ Track your team, project health, and weekly task progress in one place. + @if (summary.PendingTasksCount > 0) + { + @summary.PendingTasksCount tasks are still pending. + } +

+
+ + +
+
+ + @if (isLoading) + { +
+
+ + + + + Loading dashboard... +
+
+ } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { +
+ @errorMessage +
+ } + else + { + + +
+ + +
@WeeklyRangeLabel
+
+ + + +
+ + + +
+ +
+
+
+
+ +
+
+
+
+

@CalendarMonthLabel

+

@CalendarTodayLabel

+
+
+ +
+ @foreach (var day in WeekdayLabels) + { + @day + } +
+ +
+ @foreach (var cell in CalendarCells) + { +
+
+
@cell.Date.Day
+
+ @foreach (var dot in cell.DeadlineDots) + { + + } +
+
+
+ } +
+
+ +
+

Upcoming Deadlines

+

Projects ending soon

+ +
+ @if (UpcomingDeadlines.Any()) + { + @foreach (var item in UpcomingDeadlines) + { +
+ +
+
@item.Name
+
ends @item.EndDateLabel
+
+
@item.DaysLeftLabel
+
+ } + } + else + { +
+ No upcoming deadlines. +
+ } +
+
+
+ +
+
+
+

My Projects

+

Click "View Tasks" to drill in

+
+ + View all + +
+ +
+ @if (ProjectCards.Any()) + { + @foreach (var project in ProjectCards) + { +
+
+
+
+ +

@project.Name

+ @project.StatusLabel +
+ +
+ @project.Completed/@project.Total tasks + @project.Overdue overdue + @project.TeamSize members +
+ +
+
+
+
+ +
+
+
@project.ProgressPercentage.ToString("F0")%
+
Complete
+
+ +
+
+
+ } + } + else + { +
No projects available for this account.
+ } +
+
+ } +
+
+ +@code { + private const double SvgWidth = 760; + private const double PlotLeft = 56; + private const double PlotRight = 724; + private const double PlotTop = 40; + private const double PlotBottom = 200; + private const double BarWidth = 18; + + private bool isLoading = true; + private string? errorMessage; + + private List projects = new(); + private List tasks = new(); + private List users = new(); + private List projectProgress = new(); + private DashboardSummaryDto summary = new(); + + private int? hoveredWeekIndex; + + private List WeeklyProgress => BuildWeeklyProgress(); + private WeeklyProgressViewModel? hoveredWeek => hoveredWeekIndex is int index && index >= 0 && index < WeeklyProgress.Count ? WeeklyProgress[index] : null; + private string HoveredWeekTooltipStyle => hoveredWeek is null + ? string.Empty + : $"left: {hoveredWeek.CenterPercent:F2}%; top: 34px; transform: translateX(-50%);"; + + private string WeeklyRangeLabel + => WeeklyProgress.Count > 0 + ? $"{WeeklyProgress.First().DateRangeLabel} - {WeeklyProgress.Last().DateRangeLabel}" + : DateTime.Today.ToString("MMMM yyyy"); + + private List SummaryMetrics => BuildSummaryMetrics(); + private List ProjectCards => BuildProjectCards(); + private List TeamProgress => BuildTeamProgress(); + private List TeamProgressItems => TeamProgress + .Select(member => new ProgressListChartItem( + member.FullName, + member.CompletionPercentage, + $"{member.Completed}/{member.Total}", + member.Color, + $"{member.Label} - {member.CompletionPercentage:F0}%", + member.Initials)) + .ToList(); + private List WeeklyBarGroups => WeeklyProgress + .Select(week => new BarChartGroup( + week.Label, + new[] + { + new ChartDataPoint("Completed", week.Completed, "#3b82f6"), + new ChartDataPoint("Pending", week.Pending, "#14b8a6") + }, + week.DateRangeLabel)) + .ToList(); + private List YTicks => BuildYAxisTicks(); + private List CalendarCells => BuildCalendarCells(); + private List UpcomingDeadlines => BuildUpcomingDeadlines(); + private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy"); + private string CalendarTodayLabel => DateTime.Today.ToString("dddd, d"); + private string CurrentTimeLabel => DateTime.Now.ToString("h:mm tt"); + private string TimeZoneLabel + { + get + { + var offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now); + var sign = offset >= TimeSpan.Zero ? "+" : "-"; + var hours = Math.Abs(offset.Hours); + var minutes = Math.Abs(offset.Minutes); + return minutes == 0 ? $"GMT{sign}{hours}" : $"GMT{sign}{hours}:{minutes:00}"; + } + } + + protected override async Task OnInitializedAsync() + { + await LoadDashboardData(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadDashboardData() + { + isLoading = true; + errorMessage = null; + + try + { + var client = ApiClient.CreateClient(); + + var summaryTask = client.GetFromJsonAsync>("Dashboard/summary", Serialization.CaseInsensitive); + var progressTask = client.GetFromJsonAsync>>("Dashboard/project-progress", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var tasksTask = client.GetFromJsonAsync>("Task", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + + await Task.WhenAll(summaryTask, progressTask, projectsTask, tasksTask, usersTask); + + var summaryResult = await summaryTask; + if (summaryResult?.IsSuccess == true) + { + summary = summaryResult.Value ?? new DashboardSummaryDto(); + } + + var progressResult = await progressTask; + if (progressResult?.IsSuccess == true) + { + projectProgress = progressResult.Value?.ToList() ?? new List(); + } + + projects = (await projectsTask) ?? new List(); + tasks = (await tasksTask) ?? new List(); + users = (await usersTask) ?? new List(); + } + catch (Exception ex) + { + errorMessage = $"Failed to load dashboard data: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private void ViewProjectTasks(long projectId) + { + Navigation.NavigateTo($"/projects/{projectId}/tasks"); + } + + private void SetHoveredWeek(int index) + { + hoveredWeekIndex = index; + StateHasChanged(); + } + + private void ClearHoveredWeek() + { + if (hoveredWeekIndex is null) + { + return; + } + + hoveredWeekIndex = null; + StateHasChanged(); + } + + private List BuildSummaryMetrics() + { + var activeProjects = summary.ActiveProjectsCount > 0 ? summary.ActiveProjectsCount : projects.Count; + var totalTasks = tasks.Count; + var teamMembers = summary.TotalUsers > 0 ? summary.TotalUsers : users.Count; + var overdueTasks = tasks.Count(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done); + + return new List + { + new("folder-kanban", "all active", activeProjects.ToString(), "Active Projects", "rounded-2xl bg-violet-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("check-square", "across projects", totalTasks.ToString(), "Total Tasks", "rounded-2xl bg-cyan-600 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("users", "your team", teamMembers.ToString(), "Team Members", "rounded-2xl bg-emerald-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("triangle-alert", "needs attention", overdueTasks.ToString(), "Overdue Tasks", "rounded-2xl bg-rose-600 p-3 text-white", "text-sm font-semibold text-rose-600") + }; + } + + private List BuildProjectCards() + { + var progressByProject = projectProgress.ToDictionary(item => item.ProjectId, item => item); + var palette = new[] { "#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#f97316" }; + + return projects + .OrderByDescending(project => progressByProject.TryGetValue(project.Id, out var progress) ? progress.CompletionPercentage : 0) + .ThenBy(project => project.EndDate) + .Select((project, index) => + { + progressByProject.TryGetValue(project.Id, out var progress); + var projectTasks = tasks.Where(task => task.ProjectId == project.Id).ToList(); + var total = progress?.TotalTasksCount ?? projectTasks.Count; + var completed = progress?.CompletedTasksCount ?? projectTasks.Count(task => task.StatusId == AppTaskStatus.Done); + var percentage = total > 0 ? Math.Round((double)completed / total * 100, 2) : 0; + var overdue = projectTasks.Count(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done); + var teamSize = projectTasks + .Where(task => task.AssignedTo.HasValue) + .Select(task => task.AssignedTo!.Value) + .Distinct() + .Count(); + + var isOverdue = project.EndDate.Date < DateTime.Today && percentage < 100; + var isUpcoming = project.StartDate.Date > DateTime.Today; + var statusLabel = isOverdue ? "At Risk" : isUpcoming ? "Upcoming" : "Active"; + var statusClass = isOverdue + ? "rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300" + : isUpcoming + ? "rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-300" + : "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-300"; + + return new ProjectCardViewModel( + project.Id, + project.Name, + completed, + total, + overdue, + teamSize, + percentage, + statusLabel, + statusClass, + palette[index % palette.Length]); + }) + .ToList(); + } + + private List BuildTeamProgress() + { + var palette = new[] { "#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#f97316", "#ec4899" }; + + return users + .Select((user, index) => + { + var userTasks = tasks.Where(task => task.AssignedTo == user.Id).ToList(); + var total = userTasks.Count; + if (total == 0) + { + return null; + } + + var completed = userTasks.Count(task => task.StatusId == AppTaskStatus.Done); + var percentage = Math.Round((double)completed / total * 100, 2); + var initials = BuildInitials(user.FirstName, user.LastName); + var label = total == 1 ? "1 task" : $"{total} tasks"; + + return new TeamProgressViewModel( + user.Id, + $"{user.FirstName} {user.LastName}".Trim(), + initials, + completed, + total, + percentage, + label, + palette[index % palette.Length]); + }) + .Where(item => item is not null) + .Cast() + .OrderByDescending(item => item.Total) + .ThenBy(item => item.FullName) + .ToList(); + } + + private List BuildCalendarCells() + { + var today = DateTime.Today; + var firstOfMonth = new DateTime(today.Year, today.Month, 1); + var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek); + var deadlineMap = projects + .Where(project => project.EndDate.Date >= start.Date && project.EndDate.Date < start.AddDays(42)) + .GroupBy(project => project.EndDate.Date) + .ToDictionary(group => group.Key, group => group.Select(project => GetProjectColor(project.Id)).ToList()); + + var cells = new List(); + for (var i = 0; i < 42; i++) + { + var date = start.AddDays(i); + deadlineMap.TryGetValue(date.Date, out var dots); + + cells.Add(new CalendarCellViewModel( + date, + date.Month == today.Month, + date.Date == today.Date, + dots ?? new List())); + } + + return cells; + } + + private List BuildUpcomingDeadlines() + { + return projects + .Where(project => project.EndDate.Date >= DateTime.Today) + .OrderBy(project => project.EndDate) + .Select(project => + { + var daysLeft = (project.EndDate.Date - DateTime.Today).Days; + var label = daysLeft == 0 ? "Today" : daysLeft == 1 ? "1d" : $"{daysLeft}d"; + var labelClass = daysLeft <= 14 ? "text-amber-500 font-semibold" : "text-slate-500"; + + return new DeadlineItemViewModel( + project.Id, + project.Name, + project.EndDate.ToString("yyyy-MM-dd"), + label, + labelClass, + GetProjectColor(project.Id)); + }) + .ToList(); + } + + private List BuildWeeklyProgress() + { + var today = DateTime.Today; + var currentWeekStart = StartOfWeek(today); + var weekStarts = Enumerable.Range(-3, 4) + .Select(offset => currentWeekStart.AddDays(offset * 7)) + .ToList(); + + var rows = new List(); + var maxTotal = 1; + + foreach (var weekStart in weekStarts) + { + var weekEnd = weekStart.AddDays(7); + var weekTasks = tasks.Where(task => task.CreatedAt >= weekStart && task.CreatedAt < weekEnd).ToList(); + var completed = weekTasks.Count(task => task.StatusId == AppTaskStatus.Done); + var pending = weekTasks.Count - completed; + maxTotal = Math.Max(maxTotal, completed + pending); + + rows.Add(new WeeklyProgressViewModel( + $"W{rows.Count + 1}", + weekStart, + weekEnd.AddDays(-1), + completed, + pending, + 0, + 0, + 0, + 0, + PlotBottom, + 0, + 0, + FormatDateRange(weekStart, weekEnd.AddDays(-1)))); + } + + if (rows.Count == 0) + { + return rows; + } + + var slotWidth = (PlotRight - PlotLeft) / 4.0; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var total = row.Completed + row.Pending; + var completedHeight = total > 0 ? Math.Round(row.Completed / (double)maxTotal * (PlotBottom - PlotTop), 2) : 0; + var pendingHeight = total > 0 ? Math.Round(row.Pending / (double)maxTotal * (PlotBottom - PlotTop), 2) : 0; + var centerX = PlotLeft + (slotWidth * i) + (slotWidth / 2); + var completedX = centerX - 22; + var pendingX = centerX + 4; + var hoverX = PlotLeft + (slotWidth * i) + 10; + var hoverWidth = slotWidth - 20; + + rows[i] = row with + { + CompletedHeight = completedHeight, + PendingHeight = pendingHeight, + CompletedX = completedX, + PendingX = pendingX, + HoverX = hoverX, + HoverWidth = hoverWidth, + LabelX = centerX, + CenterPercent = centerX / SvgWidth * 100 + }; + } + + return rows; + } + + private List BuildYAxisTicks() + { + var max = Math.Max(1, WeeklyProgress.Max(item => item.Completed + item.Pending)); + var interval = Math.Max(1, (int)Math.Ceiling(max / 4.0)); + var highest = interval * 4; + + return new List + { + new(0, "0"), + new(highest * 0.25, (highest * 0.25).ToString("0")), + new(highest * 0.5, (highest * 0.5).ToString("0")), + new(highest * 0.75, (highest * 0.75).ToString("0")), + new(highest, highest.ToString("0")) + }; + } + + private double TickToY(double tickValue) + { + var max = Math.Max(1, WeeklyProgress.Max(item => item.Completed + item.Pending)); + var chartHeight = PlotBottom - PlotTop; + return PlotBottom - (Math.Min(tickValue, max) / max * chartHeight); + } + + private static DateTime StartOfWeek(DateTime date) + { + var diff = (int)date.DayOfWeek; + return date.AddDays(-diff).Date; + } + + private static string FormatDateRange(DateTime start, DateTime end) + { + if (start.Month == end.Month && start.Year == end.Year) + { + return $"{start:MMM d} - {end:dd}"; + } + + return $"{start:MMM d} - {end:MMM d}"; + } + + private static string BuildInitials(string firstName, string lastName) + { + var first = string.IsNullOrWhiteSpace(firstName) ? string.Empty : firstName.Trim()[0].ToString().ToUpperInvariant(); + var last = string.IsNullOrWhiteSpace(lastName) ? string.Empty : lastName.Trim()[0].ToString().ToUpperInvariant(); + return $"{first}{last}"; + } + + private string GetProjectColor(long projectId) + { + var palette = new[] + { + "#2563eb", + "#0ea5e9", + "#10b981", + "#f59e0b", + "#f97316" + }; + + return palette[(int)(Math.Abs(projectId) % palette.Length)]; + } + + private string GetCalendarCellClasses(CalendarCellViewModel cell) + { + var classes = new List { "min-h-[62px] rounded-2xl border p-2" }; + + if (cell.IsToday) + { + classes.Add("border-violet-600 bg-violet-600"); + } + else if (cell.IsCurrentMonth) + { + classes.Add("border-slate-200 bg-white"); + } + else + { + classes.Add("border-slate-100 bg-slate-50 text-slate-400"); + } + + return string.Join(" ", classes); + } + + private sealed record ProjectCardViewModel( + long Id, + string Name, + int Completed, + int Total, + int Overdue, + int TeamSize, + double ProgressPercentage, + string StatusLabel, + string StatusClass, + string Color); + + private sealed record TeamProgressViewModel( + long UserId, + string FullName, + string Initials, + int Completed, + int Total, + double CompletionPercentage, + string Label, + string Color); + + private sealed record WeeklyProgressViewModel( + string Label, + DateTime StartDate, + DateTime EndDate, + int Completed, + int Pending, + double CompletedHeight, + double PendingHeight, + double CompletedX, + double PendingX, + double BaseY, + double HoverX, + double HoverWidth, + string DateRangeLabel) + { + public double LabelX { get; init; } + public double CenterPercent { get; init; } + } + + private sealed record YAxisTickViewModel(double Value, string Label); + + private sealed record CalendarCellViewModel( + DateTime Date, + bool IsCurrentMonth, + bool IsToday, + List DeadlineDots); + + private sealed record DeadlineItemViewModel( + long ProjectId, + string Name, + string EndDateLabel, + string DaysLeftLabel, + string DaysLeftClass, + string Color); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor new file mode 100644 index 0000000000000000000000000000000000000000..b3f996d2408530c8a5af8bc66b78fa7a703328d6 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor @@ -0,0 +1,565 @@ +@page "/issues/add" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.Shared.Models.Issue +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.User +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager Navigation + +@AppLocalization.PageTitle("addIssue", "Add Issue") + + + +
+
+
+

@AppLocalization.Text("common.addIssue", "Add Issue")

+

@AppLocalization.Text("issue.addIssueDescription", "Create a one-day work issue for a task and record the hours you spent.")

+
+
+ + @if (isLoading) + { + + } + else + { +
+
+
+ + + @if (isTaskError) + { +

@taskErrorMessage

+ } +
+ +
+ + + @if (isTitleError) + { +

@titleErrorMessage

+ } +
+ +
+ + +
+ +
+ + +
+ +
+ + +

@AppLocalization.Text("issue.actualHoursHint", "Use the total hours you spent today. For your standard day, this is usually 7 hours.")

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + @if (isAssigneeDropdownOpen) + { +
+
+ +
+
+ + +
+ + @if (isLoadingProjectMembers) + { +
+ @AppLocalization.Text("common.loadingProjectMembers", "Loading project members...") +
+ } + else + { +
+ + + @foreach (var user in FilteredProjectMembers) + { + var isSelected = formAssignedTo == user.Id; + + } + + @if (!FilteredProjectMembers.Any()) + { +
+ @AppLocalization.Text("common.noMembersMatchSearch", "No members match the current search.") +
+ } +
+ } +
+ } +
+
+
+ +
+ + +
+ + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { +

@errorMessage

+ } +
+ } +
+ + + +@code { + private bool isLoading = true; + private bool isSaving; + private bool showConfirmDialog; + private bool isTaskError; + private bool isTitleError; + private string taskErrorMessage = ""; + private string titleErrorMessage = ""; + private string? errorMessage; + private string? successMessage; + + private List tasks = new(); + private List projects = new(); + private List projectMembers = new(); + private long currentUserId; + + private long formTaskId; + private string formTitle = ""; + private string? formDescription; + private long? formAssignedTo; + private decimal? formEstimatedHours; + private decimal? formActualHours = 7m; + private DateTime formStartDate = DateTime.Today; + private DateTime formDueDate = DateTime.Today; + private AppTaskStatus formStatusId = AppTaskStatus.Todo; + private TaskPriority formPriorityId = TaskPriority.Medium; + private string? formDelayReason; + private bool formIsBlocked; + private string? formBlockedBy; + private int formEscalationLevel; + private string assigneeSearchInput = ""; + private bool isAssigneeDropdownOpen; + private bool isLoadingProjectMembers; + + private TaskDto? SelectedTask => tasks.FirstOrDefault(t => t.Id == formTaskId); + private string SelectedTaskLabel => SelectedTask == null ? "No task selected" : SelectedTask.Title; + private string SelectedProjectLabel => SelectedTask == null + ? "No project" + : (projects.FirstOrDefault(p => p.Id == SelectedTask.ProjectId)?.Name ?? $"Project #{SelectedTask.ProjectId}"); + private string SelectedAssigneeLabel => formAssignedTo.HasValue + ? (projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value) is { } user ? $"{user.FirstName} {user.LastName}" : "Selected member") + : "Unassigned"; + + private IEnumerable FilteredProjectMembers + { + get + { + var result = projectMembers.Where(u => u.IsActive); + if (!string.IsNullOrWhiteSpace(assigneeSearchInput)) + { + var search = assigneeSearchInput.Trim(); + result = result.Where(u => + $"{u.FirstName} {u.LastName}".Contains(search, StringComparison.OrdinalIgnoreCase) || + u.Username.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + return result.OrderBy(u => u.FirstName).ThenBy(u => u.LastName); + } + } + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) + { + long.TryParse(idStr, out currentUserId); + } + + await LoadDataAsync(user); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadDataAsync(System.Security.Claims.ClaimsPrincipal user) + { + var client = ApiClient.CreateClient(); + try + { + var tasksTask = client.GetFromJsonAsync>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + + await Task.WhenAll(tasksTask, projectsTask, usersTask); + + tasks = tasksTask.Result ?? new(); + projects = projectsTask.Result ?? new(); + + var allUsers = usersTask.Result ?? new(); + projectMembers = allUsers.Where(u => u.IsActive).ToList(); + + formTaskId = tasks.FirstOrDefault()?.Id ?? 0; + await LoadProjectMembersForTaskAsync(formTaskId); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isLoading = false; + } + } + + private async Task OnTaskChanged(ChangeEventArgs e) + { + if (long.TryParse(e.Value?.ToString(), out var taskId)) + { + formTaskId = taskId; + await LoadProjectMembersForTaskAsync(formTaskId); + if (formTaskId > 0) + { + formAssignedTo = currentUserId; + } + } + } + + private async Task LoadProjectMembersForTaskAsync(long taskId) + { + isLoadingProjectMembers = true; + projectMembers.Clear(); + + var task = tasks.FirstOrDefault(t => t.Id == taskId); + if (task == null) + { + formAssignedTo = null; + isLoadingProjectMembers = false; + return; + } + + var client = ApiClient.CreateClient(); + try + { + var membersResult = await client.GetFromJsonAsync>>($"Project/{task.ProjectId}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + projectMembers = membersResult.Value.Where(u => u.IsActive).OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList(); + if (projectMembers.Any(u => u.Id == currentUserId)) + { + formAssignedTo = currentUserId; + } + else if (formAssignedTo.HasValue && !projectMembers.Any(u => u.Id == formAssignedTo.Value)) + { + formAssignedTo = projectMembers.FirstOrDefault()?.Id; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to load project members: {ex.Message}"); + } + finally + { + isLoadingProjectMembers = false; + } + } + + private void ToggleAssigneeDropdown() + { + isAssigneeDropdownOpen = !isAssigneeDropdownOpen; + if (isAssigneeDropdownOpen) + { + assigneeSearchInput = ""; + } + } + + private void CloseAssigneeDropdown() + { + isAssigneeDropdownOpen = false; + } + + private void EnsureAssigneeDropdownOpen() + { + isAssigneeDropdownOpen = true; + } + + private void SetFormAssignee(long? userId) + { + formAssignedTo = userId; + isAssigneeDropdownOpen = false; + assigneeSearchInput = ""; + } + + private void RequestSaveConfirmation() + { + isTaskError = false; + isTitleError = false; + taskErrorMessage = ""; + titleErrorMessage = ""; + + if (formTaskId <= 0) + { + isTaskError = true; + taskErrorMessage = "Please select a task."; + return; + } + + if (string.IsNullOrWhiteSpace(formTitle)) + { + isTitleError = true; + titleErrorMessage = "Issue title is required."; + return; + } + + showConfirmDialog = true; + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task ConfirmSave() + { + showConfirmDialog = false; + await SaveIssueAsync(); + } + + private async Task SaveIssueAsync() + { + if (formTaskId <= 0) + { + errorMessage = "Please select a task."; + return; + } + + if (string.IsNullOrWhiteSpace(formTitle)) + { + errorMessage = "Issue title is required."; + return; + } + + isSaving = true; + errorMessage = null; + + try + { + var client = ApiClient.CreateClient(); + var dto = new CreateIssueDto + { + TaskId = formTaskId, + Title = formTitle.Trim(), + Description = formDescription, + AssignedTo = formAssignedTo, + EstimatedHours = formEstimatedHours, + ActualHours = formActualHours, + DelayReason = formDelayReason, + IsBlocked = formIsBlocked, + BlockedBy = formBlockedBy, + EscalationLevel = formEscalationLevel, + StartDate = formStartDate, + DueDate = formDueDate, + StatusId = formStatusId, + PriorityId = formPriorityId + }; + + var response = await client.PostAsJsonAsync("Issue", dto); + var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + + if (result?.IsSuccess == true) + { + successMessage = "Issue created successfully!"; + ResetForm(); + } + else + { + errorMessage = result?.ErrorMessage ?? "Failed to create issue."; + } + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isSaving = false; + } + } + + private void ResetForm() + { + formTaskId = tasks.FirstOrDefault()?.Id ?? 0; + formTitle = ""; + formDescription = null; + formAssignedTo = currentUserId; + formEstimatedHours = null; + formActualHours = 7m; + formStartDate = DateTime.Today; + formDueDate = DateTime.Today; + formStatusId = AppTaskStatus.Todo; + formPriorityId = TaskPriority.Medium; + formDelayReason = null; + formIsBlocked = false; + formBlockedBy = null; + formEscalationLevel = 0; + taskErrorMessage = ""; + titleErrorMessage = ""; + isTaskError = false; + isTitleError = false; + assigneeSearchInput = ""; + isAssigneeDropdownOpen = false; + _ = LoadProjectMembersForTaskAsync(formTaskId); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor new file mode 100644 index 0000000000000000000000000000000000000000..60b60125d40fcd0213300f30d1696fb497c607d4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor @@ -0,0 +1,806 @@ +@page "/issues" +@page "/issues/all" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.Shared.Models.Issue +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.User +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager Navigation + +@AppLocalization.PageTitle("issues", "Issues") + + + +
+
+
+

@AppLocalization.PageTitle("issues", "Issues")

+

@AppLocalization.Text("page.issues.desc", "Track daily work items, update progress, and review what was done.")

+
+ @if (canCreateIssue) + { + + } +
+ + @if (isLoading) + { + + } + else + { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + @if (issues.Any()) + { + @foreach (var entry in issues.Select((issue, index) => (issue, index))) + { + var issue = entry.issue; + + + + + + + + + + + + } + } + else + { + + } + +
@AppLocalization.Text("common.taskCode", "Task Code")@AppLocalization.Text("common.issue", "Issue")@AppLocalization.Text("common.hours", "Hours")@AppLocalization.Text("common.status", "Status")@AppLocalization.Text("common.priority", "Priority")@AppLocalization.Text("common.date", "Date")@AppLocalization.Text("common.assignee", "Assignee")@AppLocalization.Text("common.risk", "Risk")@AppLocalization.Text("common.actions", "Actions")
+ + @GetTaskCode(issue.TaskId) + + + + + @(issue.ActualHours?.ToString("0.##") ?? "-") @AppLocalization.Text("common.hoursAbbrev", "hr") + + + @GetStatusLabel(issue.StatusId) + + + + @GetPriorityLabel(issue.PriorityId) + + + @AppLocalization.Text("common.due", "Due"): @issue.DueDate.ToString("MMM d, yyyy") + + @(!string.IsNullOrWhiteSpace(issue.AssignedToName) ? issue.AssignedToName : AppLocalization.Text("common.unassigned", "Unassigned")) + + @if (issue.IsBlocked) + { + @AppLocalization.Text("status.blocked", "Blocked") + } + else if (issue.EscalationLevel > 0) + { + @GetEscalationLabel(issue.EscalationLevel) + } + else + { + @AppLocalization.Text("status.clear", "Clear") + } + +
+ @if (canEditIssue) + { + + } + @if (canDeleteIssue) + { + + } +
+
+
+ + +
+ } +
+ +@if (showModal) +{ +
+
+
+
+

@AppLocalization.Text("common.editIssue", "Edit Issue")

+

#@editingId

+
+ +
+ +
+
+
+

@AppLocalization.Text("common.task", "Task")

+

@SelectedTaskLabel

+
+
+

@AppLocalization.Text("common.project", "Project")

+

@SelectedProjectLabel

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { +
+

@errorMessage

+
+ } +
+ +
+ + +
+
+
+} + + + + + +@code { + private bool isLoading = true; + private bool isSaving; + private bool canCreateIssue; + private bool canEditIssue; + private bool canDeleteIssue; + private bool isAdmin; + private bool showModal; + private bool showDeleteConfirm; + private bool showSaveConfirm; + + private string? successMessage; + private string? errorMessage; + private string deleteConfirmMessage = "Are you sure you want to delete this issue?"; + private long deleteIssueId; + private long currentUserId; + + private List issues = new(); + private List tasks = new(); + private List projects = new(); + private List projectMembers = new(); + + private int currentPage = 1; + private int pageSize = 10; + private int totalCount; + private int totalPages; + + private string searchInput = ""; + private long taskIdInput; + private long projectIdInput; + private AppTaskStatus? statusIdInput; + private TaskPriority? priorityIdInput; + private bool assignedOnly; + + private long editingId; + private long formTaskId; + private string formTitle = ""; + private string? formDescription; + private long? formAssignedTo; + private decimal? formActualHours = 7m; + private DateTime formStartDate = DateTime.Today; + private DateTime formDueDate = DateTime.Today; + private AppTaskStatus formStatusId = AppTaskStatus.Todo; + private TaskPriority formPriorityId = TaskPriority.Medium; + private string? formDelayReason; + private bool formIsBlocked; + private string? formBlockedBy; + private int formEscalationLevel; + + private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId)?.Title ?? $"Task #{formTaskId}"; + private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task + ? projects.FirstOrDefault(p => p.Id == task.ProjectId)?.Name ?? $"Project #{task.ProjectId}" + : "Unknown project"; + + private IEnumerable filteredProjectMembers => projectMembers.Where(u => u.IsActive); + private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}"; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + + if (long.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var userId)) + { + currentUserId = userId; + } + + canCreateIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Create"); + canEditIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Update"); + canDeleteIssue = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Issues_Delete"); + + var client = ApiClient.CreateClient(); + try + { + var issuesTask = LoadIssuesPageAsync(client); + var tasksTask = client.GetFromJsonAsync>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + + await System.Threading.Tasks.Task.WhenAll(issuesTask, tasksTask, projectsTask, usersTask); + + ApplyPageResult(issuesTask.Result); + tasks = tasksTask.Result ?? new(); + projects = projectsTask.Result ?? new(); + projectMembers = usersTask.Result?.Where(u => u.IsActive).OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList() ?? new(); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task?> LoadIssuesPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + return await client.GetFromJsonAsync>(BuildIssuesUrl(), Serialization.CaseInsensitive); + } + + private void ApplyPageResult(PagedResult? result) + { + issues = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + } + + private async Task LoadIssues() + { + var client = ApiClient.CreateClient(); + var result = await LoadIssuesPageAsync(client); + ApplyPageResult(result); + } + + private string BuildIssuesUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + if (taskIdInput > 0) + { + query.Add($"taskId={taskIdInput}"); + } + + if (projectIdInput > 0) + { + query.Add($"projectId={projectIdInput}"); + } + + if (statusIdInput.HasValue) + { + query.Add($"statusId={(int)statusIdInput.Value}"); + } + + if (priorityIdInput.HasValue) + { + query.Add($"priorityId={(int)priorityIdInput.Value}"); + } + + if (assignedOnly) + { + query.Add("assignedOnly=true"); + } + + return $"Issue?{string.Join("&", query)}"; + } + + private async Task ApplySearch() + { + currentPage = 1; + await LoadIssues(); + } + + private async Task ResetSearch() + { + searchInput = ""; + taskIdInput = 0; + projectIdInput = 0; + statusIdInput = null; + priorityIdInput = null; + assignedOnly = false; + currentPage = 1; + await LoadIssues(); + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadIssues(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadIssues(); + } + + private void GoToAddIssue() + { + Navigation.NavigateTo("/issues/add"); + } + + private async Task OpenEditModal(IssueDto issue) + { + if (!canEditIssue) + { + return; + } + + editingId = issue.Id; + formTaskId = issue.TaskId; + formTitle = issue.Title; + formDescription = issue.Description; + formAssignedTo = issue.AssignedTo; + formActualHours = issue.ActualHours; + formStartDate = issue.StartDate; + formDueDate = issue.DueDate; + formStatusId = issue.StatusId; + formPriorityId = issue.PriorityId; + formDelayReason = issue.DelayReason; + formIsBlocked = issue.IsBlocked; + formBlockedBy = issue.BlockedBy; + formEscalationLevel = issue.EscalationLevel; + errorMessage = null; + showModal = true; + + await LoadMembersForTaskAsync(issue.TaskId); + } + + private void CloseModal() + { + showModal = false; + errorMessage = null; + editingId = 0; + } + + private async Task LoadMembersForTaskAsync(long taskId) + { + projectMembers.Clear(); + + var task = tasks.FirstOrDefault(t => t.Id == taskId); + if (task == null) + { + return; + } + + var client = ApiClient.CreateClient(); + try + { + var membersResult = await client.GetFromJsonAsync>>($"Project/{task.ProjectId}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + projectMembers = membersResult.Value.Where(u => u.IsActive).OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to load issue assignees: {ex.Message}"); + } + } + + private void RequestSaveConfirmation() + { + showSaveConfirm = true; + } + + private void CloseSaveConfirm() + { + showSaveConfirm = false; + } + + private async Task ConfirmSave() + { + showSaveConfirm = false; + await SaveIssueAsync(); + } + + private async Task SaveIssueAsync() + { + if (editingId <= 0) + { + return; + } + + isSaving = true; + errorMessage = null; + + try + { + var client = ApiClient.CreateClient(); + var dto = new UpdateIssueDto + { + Title = formTitle.Trim(), + Description = formDescription, + AssignedTo = formAssignedTo, + ActualHours = formActualHours, + DelayReason = formDelayReason, + IsBlocked = formIsBlocked, + BlockedBy = formBlockedBy, + EscalationLevel = formEscalationLevel, + StartDate = formStartDate, + DueDate = formDueDate, + StatusId = formStatusId, + PriorityId = formPriorityId + }; + + var response = await client.PutAsJsonAsync($"Issue/{editingId}", dto); + if (response.IsSuccessStatusCode) + { + successMessage = "Issue updated successfully!"; + showModal = false; + await LoadIssues(); + } + else + { + errorMessage = await ReadErrorMessageAsync(response); + } + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isSaving = false; + } + } + + private void RequestDeleteConfirmation(long issueId) + { + if (!canDeleteIssue) + { + return; + } + + deleteIssueId = issueId; + deleteConfirmMessage = "Are you sure you want to delete this issue?"; + showDeleteConfirm = true; + } + + private void CloseDeleteConfirm() + { + showDeleteConfirm = false; + } + + private async Task ConfirmDelete() + { + showDeleteConfirm = false; + + try + { + var client = ApiClient.CreateClient(); + var response = await client.DeleteAsync($"Issue/{deleteIssueId}"); + if (response.IsSuccessStatusCode) + { + successMessage = "Issue deleted successfully!"; + await LoadIssues(); + } + else + { + errorMessage = await ReadErrorMessageAsync(response); + } + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + } + + private static async Task ReadErrorMessageAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + return $"Request failed with status {(int)response.StatusCode}."; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("message", out var message)) + { + return message.GetString() ?? body; + } + } + catch + { + } + + return body; + } + + private string GetStatusBadge(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "bg-slate-100 text-slate-700", + AppTaskStatus.InProgress => "bg-blue-100 text-blue-700", + AppTaskStatus.Done => "bg-emerald-100 text-emerald-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => "Unknown" + }; + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-100 text-amber-700", + TaskPriority.High => "bg-red-100 text-red-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "Low", + TaskPriority.Medium => "Medium", + TaskPriority.High => "High", + _ => "Unknown" + }; + + private static string GetEscalationLabel(int level) => level switch + { + 1 => "Team Lead", + 2 => "PM", + 3 => "Manager", + _ => "None" + }; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/ProjectAssign.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/ProjectAssign.razor new file mode 100644 index 0000000000000000000000000000000000000000..8b59850da966a58b2bb339462f91146500e4cc39 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/ProjectAssign.razor @@ -0,0 +1,454 @@ +@page "/projects/assign" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Role +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.PageTitle("projectAssignment", "Project Assignment") + + + +
+ +
+
+

@AppLocalization.PageTitle("projectAssignment", "Project Assignment")

+

@AppLocalization.Text("project.assignDescription", "Assign team members to projects to control their task visibility.")

+
+
+ + @if (isLoading) + { + + } + else + { +
+ +
+
+

@AppLocalization.Text("common.selectProject", "Select Project")

+
+
+ + +
+ + +
+
+
+ @if (FilteredProjects.Any()) + { + @foreach (var proj in FilteredProjects) + { + var isSelected = SelectedProject?.Id == proj.Id; + + } + } + else + { +
@AppLocalization.Text("common.noProjectsFound", "No projects found.")
+ } +
+
+ + +
+ @if (SelectedProject == null) + { +
+
+ +
+

@AppLocalization.Text("common.noProjectSelected", "No Project Selected")

+

@AppLocalization.Text("project.selectProjectPrompt", "Please select a project from the left panel to manage user assignments.")

+
+ } + else + { + +
+
+ @AppLocalization.Text("common.currentlyManaging", "Currently Managing") +

@SelectedProject.Name

+ @if (!string.IsNullOrEmpty(SelectedProject.Description)) + { +

@SelectedProject.Description

+ } +
+
+ + @DisplayFormats.Date(SelectedProject.StartDate) - @DisplayFormats.Date(SelectedProject.EndDate) + +
+
+ + +
+
+
+ + +
+ + +
+ @if (isAdminUser) + { +
+ +
+ } + else + { +
+ @AppLocalization.Text("project.employeesOnly", "Employees only") +
+ } +
+ + | + + | + @SelectedUserIds.Count @AppLocalization.Text("common.selected", "selected") +
+
+ + +
+ @if (isLoadingMembers) + { +
+ +
+ } + else + { + @if (FilteredUsers.Any()) + { +
+ @foreach (var user in FilteredUsers) + { + var isChecked = SelectedUserIds.Contains(user.Id); + + } +
+ } + else + { +
@AppLocalization.Text("common.noTeamMembersFound", "No team members match your search criteria.")
+ } + } +
+ + +
+
+ @if (!string.IsNullOrEmpty(statusMessage)) + { +

+ + @statusMessage +

+ } +
+ @if (canSaveAssignments) + { + + } +
+ } +
+
+ } +
+ + + +@code { + private bool isLoading = true; + private bool isLoadingMembers = false; + private bool isSaving = false; + private bool canSaveAssignments = false; + private bool showConfirmDialog = false; + private string? statusMessage; + private string? successMessage; + private bool isError = false; + + private List projects = new(); + private List allUsers = new(); + private List roles = new(); + private long? adminRoleId; + private long? employeeRoleId; + private bool isAdminUser; + + private ProjectDto? SelectedProject { get; set; } + private HashSet SelectedUserIds { get; set; } = new(); + + private string projectSearchInput = ""; + private string projectSearch = ""; + private string userSearchInput = ""; + private string userSearch = ""; + private long selectedRoleId = 0; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + isAdminUser = authState.User.IsInRole("Admin"); + canSaveAssignments = authState.User.IsInRole("Admin") || await MenuAuthorization.HasAccessCodeAsync(authState.User, "Projects_Update"); + var client = ApiClient.CreateClient(); + try + { + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + var rolesTask = client.GetFromJsonAsync>("Role", Serialization.CaseInsensitive); + await Task.WhenAll(projectsTask, usersTask, rolesTask); + + projects = projectsTask.Result ?? new(); + allUsers = usersTask.Result ?? new(); + + var allLoadedRoles = rolesTask.Result ?? new(); + var adminRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)); + var employeeRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Employee", StringComparison.OrdinalIgnoreCase)); + adminRoleId = adminRole?.Id; + employeeRoleId = employeeRole?.Id; + roles = allLoadedRoles.Where(r => !string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)).ToList(); + if (!isAdminUser && employeeRoleId.HasValue) + { + selectedRoleId = employeeRoleId.Value; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading initial data: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private IEnumerable FilteredProjects => + string.IsNullOrWhiteSpace(projectSearch) + ? projects + : projects.Where(p => p.Name.Contains(projectSearch, StringComparison.OrdinalIgnoreCase)); + + private IEnumerable FilteredUsers + { + get + { + var result = allUsers.Where(u => u.IsActive); + if (adminRoleId.HasValue) + { + result = result.Where(u => u.RoleId != adminRoleId.Value); + } + if (isAdminUser && selectedRoleId > 0) + { + result = result.Where(u => u.RoleId == selectedRoleId); + } + else if (!isAdminUser && employeeRoleId.HasValue) + { + result = result.Where(u => u.RoleId == employeeRoleId.Value); + } + if (!string.IsNullOrWhiteSpace(userSearch)) + { + result = result.Where(u => $"{u.FirstName} {u.LastName}".Contains(userSearch, StringComparison.OrdinalIgnoreCase) + || u.Username.Contains(userSearch, StringComparison.OrdinalIgnoreCase)); + } + return result; + } + } + + private void ApplyProjectSearch() + { + projectSearch = projectSearchInput; + } + + private void ApplyUserSearch() + { + userSearch = userSearchInput; + } + + private void ResetProjectSearch() + { + projectSearchInput = ""; + projectSearch = ""; + } + + private void ResetUserSearch() + { + userSearchInput = ""; + userSearch = ""; + selectedRoleId = isAdminUser ? 0 : employeeRoleId ?? 0; + } + + private async Task SelectProject(ProjectDto project) + { + SelectedProject = project; + SelectedUserIds.Clear(); + statusMessage = null; + isLoadingMembers = true; + StateHasChanged(); + + var client = ApiClient.CreateClient(); + try + { + var membersResult = await client.GetFromJsonAsync>>($"Project/{project.Id}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + SelectedUserIds = new HashSet(membersResult.Value.Select(u => u.Id)); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading project members: {ex.Message}"); + statusMessage = "Failed to load project members."; + isError = true; + } + finally + { + isLoadingMembers = false; + StateHasChanged(); + await JS.InvokeVoidAsync("initIcons"); + } + } + + private void ToggleUserSelection(long userId, bool isChecked) + { + if (isChecked) SelectedUserIds.Add(userId); + else SelectedUserIds.Remove(userId); + statusMessage = null; + } + + private void SelectAllUsers() + { + foreach (var u in FilteredUsers) + { + SelectedUserIds.Add(u.Id); + } + statusMessage = null; + } + + private void DeselectAllUsers() + { + foreach (var u in FilteredUsers) + { + SelectedUserIds.Remove(u.Id); + } + statusMessage = null; + } + + private void RequestSaveConfirmation() + { + if (SelectedProject == null) return; + showConfirmDialog = true; + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task ConfirmSaveAssignments() + { + showConfirmDialog = false; + await SaveAssignments(); + } + + private async Task SaveAssignments() + { + if (SelectedProject == null) return; + + isSaving = true; + statusMessage = null; + var client = ApiClient.CreateClient(); + + try + { + var dto = new AssignMembersDto { UserIds = SelectedUserIds.ToList() }; + var response = await client.PostAsJsonAsync($"Project/{SelectedProject.Id}/members", dto); + if (response.IsSuccessStatusCode) + { + statusMessage = "Project assignments saved successfully!"; + successMessage = "Assignment saved successfully!"; + isError = false; + Navigation.NavigateTo("/projects/all"); + } + else + { + statusMessage = "Failed to save assignment. Please try again."; + isError = true; + } + } + catch (Exception ex) + { + statusMessage = $"Error: {ex.Message}"; + isError = true; + } + finally + { + isSaving = false; + await JS.InvokeVoidAsync("initIcons"); + } + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/Projects.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/Projects.razor new file mode 100644 index 0000000000000000000000000000000000000000..510b9692998f71db0ac262990361849c3edf18d4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/Projects.razor @@ -0,0 +1,738 @@ +@page "/projects" +@page "/projects/all" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + + +Projects + + + +
+ +
+
+

Projects

+

@(isAdmin ? "Manage all projects." : "Your assigned projects.")

+
+ @if (canCreateProject) + { + + } +
+ + @if (isLoading) + { + + } + else + { + +
+
+
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + + @if (PagedProjects.Any()) + { + @foreach (var entry in PagedProjects.Select((project, index) => (project, index))) + { + var project = entry.project; + + + + + + + + + } + } + else + { + + } + +
No.NameStart DateEnd DateCreatedActions
@((currentPage - 1) * pageSize + entry.index + 1) + + @if (!string.IsNullOrEmpty(project.Description)) + { +

@project.Description

+ } +
@DisplayFormats.Date(project.StartDate)@DisplayFormats.Date(project.EndDate)@DisplayFormats.Date(project.CreatedAt) +
+ @if (canEditProject || canDeleteProject) + { + @if (canEditProject) + { + + } + @if (canDeleteProject) + { + + } + } +
+
+ + + +
+ } +
+ +@* ===== Create / Edit Modal ===== *@ +@if (showModal) +{ +
+
+
+

@(isEditing ? "Edit Project" : "New Project")

+ +
+
+
+ +
+ + @if (isNameError) + { + + } +
+ @if (isNameError) + { +

@nameErrorMessage

+ } +
+
+ + +
+
+
+ +
+ + @if (isDateError) + { + + } +
+
+
+ +
+ + @if (isDateError) + { + + } +
+
+
+
+ + +
+ @if (isDateError) + { +

@dateErrorMessage

+ } + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+

@errorMessage

+
+ } +
+
+ + +
+
+
+} + +@* ===== Assign Members Modal ===== *@ +@if (showAssignModal) +{ +
+
+
+
+

Assign Members

+

Project: @assigningProjectName

+
+ +
+
+

Select users to assign to this project. They will only see this project's tasks when they log in.

+ + @if (isLoadingUsers) + { +
+ } + else + { +
+ @foreach (var user in allUsers) + { + var isChecked = selectedUserIds.Contains(user.Id); + + } +
+

@selectedUserIds.Count user(s) selected

+ } + + @if (!string.IsNullOrEmpty(assignError)) + { +
+

@assignError

+
+ } + @if (!string.IsNullOrEmpty(assignSuccess)) + { +
+

@assignSuccess

+
+ } +
+
+ + +
+
+
+} + +@* ===== Confirm Dialog ===== *@ + + + + +@code { + private bool isLoading = true; + private bool isAdmin = false; + private bool canCreateProject = false; + private bool canEditProject = false; + private bool canDeleteProject = false; + private bool canAssignProject = false; + private long currentUserId = 0; + private bool showModal = false; + private bool isEditing = false; + private bool isSaving = false; + private string? errorMessage; + + // Confirm dialog state + private bool showConfirmDialog = false; + private bool showAssignConfirmDialog = false; + private string? successMessage; + private string confirmTitle = ""; + private string confirmMessage = ""; + private string confirmButtonText = ""; + private string confirmIcon = ""; + private string confirmIconBg = ""; + private string confirmIconText = ""; + private string confirmButtonClass = ""; + private Func? confirmAction; + private List projects = new(); + + // Assignment modal state + private bool showAssignModal = false; + private long assigningProjectId = 0; + private string assigningProjectName = ""; + private List allUsers = new(); + private HashSet selectedUserIds = new(); + private bool isLoadingUsers = false; + private bool isSavingAssign = false; + private string? assignError; + private string? assignSuccess; + + // Search state + private string searchInput = ""; + + // Pagination state + private int currentPage = 1; + private int pageSize = 10; + private int totalCount = 0; + private int totalPages = 0; + private int TotalPages => totalPages; + private int TotalCount => totalCount; + private IEnumerable PagedProjects => projects; + + // Form fields + private long editingId; + private string formName = ""; + private string? formDescription; + private DateTime formStartDate = DateTime.Today; + private DateTime formEndDate = DateTime.Today.AddMonths(1); + private int? formBudgetedHours; + + // Validation states + private bool isNameError; + private string nameErrorMessage = ""; + private bool isDateError; + private string dateErrorMessage = ""; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) long.TryParse(idStr, out currentUserId); + await LoadProjectAccessAsync(user); + await LoadProjects(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadProjects() + { + isLoading = true; + var client = ApiClient.CreateClient(); + try + { + var url = BuildProjectsUrl(); + var result = await client.GetFromJsonAsync>(url, Serialization.CaseInsensitive); + projects = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + } + catch (Exception ex) + { + Console.WriteLine($"Load projects error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private string BuildProjectsUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + return $"Project?{string.Join("&", query)}"; + } + + private void NavigateToKanban(long projectId) + { + Navigation.NavigateTo($"/projects/{projectId}/tasks"); + } + + private async Task HandlePageChanged(int page) { currentPage = page; await LoadProjects(); } + private async Task HandlePageSizeChanged(int size) { pageSize = size; currentPage = 1; await LoadProjects(); } + + private async Task ApplySearch() + { + currentPage = 1; + await LoadProjects(); + } + + private async Task ResetSearch() + { + searchInput = ""; + currentPage = 1; + await LoadProjects(); + } + + private async Task LoadProjectAccessAsync(System.Security.Claims.ClaimsPrincipal user) + { + canCreateProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Create"); + canEditProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Update"); + canDeleteProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Delete"); + canAssignProject = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Projects_Update"); + } + + private void ClearValidation() + { + isNameError = false; + nameErrorMessage = ""; + isDateError = false; + dateErrorMessage = ""; + errorMessage = null; + } + + private void OpenCreateModal() + { + isEditing = false; + editingId = 0; + formName = ""; + formDescription = null; + formStartDate = DateTime.Today; + formEndDate = DateTime.Today.AddMonths(1); + formBudgetedHours = null; + ClearValidation(); + showModal = true; + } + + private void OpenEditModal(ProjectDto project) + { + isEditing = true; + editingId = project.Id; + formName = project.Name; + formDescription = project.Description; + formStartDate = project.StartDate; + formEndDate = project.EndDate; + formBudgetedHours = project.BudgetedHours; + ClearValidation(); + showModal = true; + } + + private void CloseModal() + { + showModal = false; + ClearValidation(); + } + + // ─── Assignment Modal ───────────────────────────────────────────────────── + + private async Task OpenAssignModal(ProjectDto project) + { + assigningProjectId = project.Id; + assigningProjectName = project.Name; + assignError = null; + assignSuccess = null; + isLoadingUsers = true; + showAssignModal = true; + StateHasChanged(); + + var client = ApiClient.CreateClient(); + try + { + allUsers = await client.GetFromJsonAsync>("User", Serialization.CaseInsensitive) ?? new(); + var membersResult = await client.GetFromJsonAsync>>($"Project/{project.Id}/members", Serialization.CaseInsensitive); + selectedUserIds = new HashSet(membersResult?.Value?.Select(u => u.Id) ?? Enumerable.Empty()); + } + catch (Exception ex) + { + Console.WriteLine($"Load assign modal error: {ex.Message}"); + } + finally + { + isLoadingUsers = false; + StateHasChanged(); + } + } + + private void CloseAssignModal() + { + showAssignModal = false; + assignError = null; + assignSuccess = null; + } + + private void ToggleUserSelection(long userId, bool isChecked) + { + if (isChecked) selectedUserIds.Add(userId); + else selectedUserIds.Remove(userId); + } + + private void RequestAssignConfirmation() + { + showAssignConfirmDialog = true; + } + + private void CloseAssignConfirmDialog() + { + showAssignConfirmDialog = false; + } + + private async Task ConfirmSaveAssignment() + { + showAssignConfirmDialog = false; + await SaveAssignment(); + } + + private async Task SaveAssignment() + { + isSavingAssign = true; + assignError = null; + assignSuccess = null; + var client = ApiClient.CreateClient(); + try + { + var dto = new AssignMembersDto { UserIds = selectedUserIds.ToList() }; + var response = await client.PostAsJsonAsync($"Project/{assigningProjectId}/members", dto); + if (response.IsSuccessStatusCode) + { + assignSuccess = "Members assigned successfully!"; + successMessage = "Assignment saved successfully!"; + } + else + { + assignError = "Failed to save assignment. Please try again."; + } + } + catch (Exception ex) + { + assignError = ex.Message; + } + finally + { + isSavingAssign = false; + } + } + + private void RequestSaveConfirmation() + { + ClearValidation(); + bool hasError = false; + + if (string.IsNullOrWhiteSpace(formName)) + { + isNameError = true; + nameErrorMessage = "Project name is required."; + hasError = true; + } + if (formEndDate < formStartDate) + { + isDateError = true; + dateErrorMessage = "End date cannot be before start date."; + hasError = true; + } + + if (hasError) return; + + if (isEditing) + { + confirmTitle = "Update Project?"; + confirmMessage = "Are you sure you want to update this project?"; + confirmButtonText = "Update"; + confirmIcon = "pencil"; + confirmIconBg = "bg-amber-50"; + confirmIconText = "text-amber-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + else + { + confirmTitle = "Create Project?"; + confirmMessage = "Are you sure you want to create this project?"; + confirmButtonText = "Create"; + confirmIcon = "plus-circle"; + confirmIconBg = "bg-emerald-50"; + confirmIconText = "text-emerald-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + confirmAction = SaveProject; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = "Delete Project?"; + confirmMessage = "Are you sure you want to delete this project? This action cannot be undone."; + confirmButtonText = "Delete"; + confirmIcon = "trash-2"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + confirmAction = () => DeleteProject(id); + showConfirmDialog = true; + } + + private async Task HandleConfirmAction() + { + showConfirmDialog = false; + if (confirmAction != null) + await confirmAction(); + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task SaveProject() + { + if (string.IsNullOrWhiteSpace(formName) || formEndDate < formStartDate) + { + return; + } + + isSaving = true; + errorMessage = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateProjectDto + { + Name = formName, + Description = formDescription, + StartDate = formStartDate, + EndDate = formEndDate, + BudgetedHours = formBudgetedHours + }; + var response = await client.PutAsJsonAsync($"Project/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateProject; + return; + } + } + else + { + var dto = new CreateProjectDto + { + Name = formName, + Description = formDescription, + StartDate = formStartDate, + EndDate = formEndDate, + CreatedById = currentUserId, + BudgetedHours = formBudgetedHours + }; + var response = await client.PostAsJsonAsync("Project", dto); + var res = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateProject; + return; + } + + // After creating, reload + await LoadProjects(); + CloseModal(); + successMessage = "Project created successfully!"; + return; + } + + CloseModal(); + await LoadProjects(); + successMessage = isEditing ? "Project updated successfully!" : "Project created successfully!"; + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isSaving = false; + } + } + + private async Task DeleteProject(long id) + { + var client = ApiClient.CreateClient(); + try + { + var response = await client.DeleteAsync($"Project/{id}"); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? "Failed to delete project."); + return; + } + await LoadProjects(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = "Project deleted successfully!"; + } + catch (Exception ex) + { + Console.WriteLine($"Delete error: {ex.Message}"); + } + } +} + diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..d9c740909d1aa369424624cf2dab9185674c1727 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor @@ -0,0 +1,32 @@ +
+ @if (!Buckets.Any()) + { +
@EmptyText
+ } + else + { + @foreach (var bucket in Buckets) + { + var height = MaxValue > 0 ? Math.Clamp(bucket.Value / MaxValue * 100, 8, 100) : 8; +
+
+
+
+
+ @bucket.Label + @bucket.Value.ToString("0") +
+ @if (!string.IsNullOrWhiteSpace(bucket.Detail)) + { +

@bucket.Detail

+ } +
+ } + } +
+ +@code { + [Parameter] public IReadOnlyList Buckets { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No aging data available."; + private double MaxValue => Math.Max(1, Buckets.Select(bucket => bucket.Value).DefaultIfEmpty(0).Max()); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..a3b6d21fc5970837c207458f5bb464010120a44f --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor @@ -0,0 +1,93 @@ +
+ @if (Total <= 0) + { +
@EmptyText
+ } + else + { +
+ + @foreach (var slice in Slices) + { + if (slice.IsFullCircle) + { + + } + else + { + + } + + } + +
+
+ @foreach (var item in Items) + { + var pct = Total > 0 ? item.Value / Total * 100 : 0; +
+
+ + @item.Label +
+ @item.Value.ToString("0") · @pct.ToString("0")% +
+ } +
+ } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public string CenterLabel { get; set; } = "total"; + [Parameter] public string EmptyText { get; set; } = "No data available."; + [Parameter] public string AriaLabel { get; set; } = "Status distribution"; + + private double Total => Items.Sum(item => Math.Max(0, item.Value)); + private IReadOnlyList Slices => BuildSlices(); + + private IReadOnlyList BuildSlices() + { + const double center = 90; + const double radius = 66; + var startAngle = -90.0; + var slices = new List(); + + foreach (var item in Items) + { + var value = Math.Max(0, item.Value); + if (value <= 0 || Total <= 0) + { + continue; + } + + var sweep = 360 * value / Total; + var endAngle = startAngle + sweep; + + if (sweep >= 359.999) + { + slices.Add(new Slice(item.Color, string.Empty, true)); + startAngle = endAngle; + continue; + } + + var start = PointOnCircle(center, center, radius, startAngle); + var end = PointOnCircle(center, center, radius, endAngle); + var largeArc = sweep > 180 ? 1 : 0; + var path = $"M {center:0.##} {center:0.##} L {start.X:0.##} {start.Y:0.##} A {radius:0.##} {radius:0.##} 0 {largeArc} 1 {end.X:0.##} {end.Y:0.##} Z"; + + slices.Add(new Slice(item.Color, path, false)); + startAngle = endAngle; + } + + return slices; + } + + private static (double X, double Y) PointOnCircle(double centerX, double centerY, double radius, double angleDegrees) + { + var radians = angleDegrees * Math.PI / 180; + return (centerX + radius * Math.Cos(radians), centerY + radius * Math.Sin(radians)); + } + + private sealed record Slice(string Color, string Path, bool IsFullCircle); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..47e68fecb0fd95ef7f03bbc505c0ecff28a060a5 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor @@ -0,0 +1,64 @@ +
+ @if (!Items.Any()) + { +
@EmptyText
+ } + else + { + @foreach (var item in Items.Select((value, index) => new { value, index })) + { + var stage = item.value; + var percent = Baseline > 0 ? stage.Value / Baseline * 100 : 0; + var width = stage.Value <= 0 ? 0 : Math.Clamp(percent, MinVisiblePercent, 100); +
+
+ @stage.Label + @GetStageHelper(stage) + @stage.Value.ToString("0") · @percent.ToString("0")% +
+
+
+ @if (stage.Value > 0) + { + @stage.Label + } +
+
+
+ } + +
+ Width is measured against @Items.First().Label.ToLowerInvariant() (@Baseline.ToString("0")). Zero-value stages keep an empty track with no colored fill. +
+ } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No funnel data available."; + [Parameter] public double MinVisiblePercent { get; set; } = 4; + + private double Baseline => Math.Max(1, Items.FirstOrDefault()?.Value ?? Items.Select(item => item.Value).DefaultIfEmpty(0).Max()); + + private static string GetFillClass(ReportChartItem item) + { + return item.Value > 0 + ? "flex h-full items-center justify-end rounded-md text-[11px] font-semibold text-white transition-all" + : "h-full rounded-md"; + } + + private static string GetStageHelper(ReportChartItem item) + { + if (item.Value <= 0) + { + return $"No {item.Label.ToLowerInvariant()}"; + } + + return item.Detail ?? string.Empty; + } + + private static string BuildTitle(ReportChartItem item, double percent) + { + return $"{item.Label}: {item.Value:0} ({percent:0}% of baseline)"; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/InsightPanel.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/InsightPanel.razor new file mode 100644 index 0000000000000000000000000000000000000000..f185a9700eda44405083521750c81c7bdf53f6e6 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/InsightPanel.razor @@ -0,0 +1,15 @@ +
+

@Title

+
+ @foreach (var insight in Insights) + { +
@insight
+ } +
+
+ +@code { + [Parameter] public string Title { get; set; } = "Key Insights"; + [Parameter] public IReadOnlyList Insights { get; set; } = Array.Empty(); + [Parameter] public string PanelClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-5 shadow-sm"; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..801f8bed44b924fdf3ad825e72674714da240229 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor @@ -0,0 +1,26 @@ + +
+
+
+
@PrimaryLabel
+
@PrimaryValue
+
+
+
@SecondaryLabel
+
@SecondaryValue
+
+
+ +
+
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string PrimaryLabel { get; set; } = "Actual"; + [Parameter] public string PrimaryValue { get; set; } = "0"; + [Parameter] public string SecondaryLabel { get; set; } = "Target"; + [Parameter] public string SecondaryValue { get; set; } = "0"; + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList Segments { get; set; } = Array.Empty(); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..d57846c63c612631a0a3e120e0e6729c8074f715 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor @@ -0,0 +1,37 @@ + +
+
+ + + + +
+ @ClampedPercent.ToString("0")% + @CenterLabel +
+
+
+
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public string? ValueLabel { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public string CenterLabel { get; set; } = "score"; + [Parameter] public double Percent { get; set; } + [Parameter] public string Color { get; set; } = "#7c3aed"; + + private double ClampedPercent => Math.Clamp(Percent, 0, 100); + private string DashArray + { + get + { + const double radius = 58; + var circumference = 2 * Math.PI * radius; + var arc = circumference * ClampedPercent / 100; + return $"{arc:0.##} {(circumference - arc):0.##}"; + } + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..02e41cd550781da9044cfe21c8ad667d0c434f13 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartCard.razor @@ -0,0 +1,43 @@ +
+
+
+

@Title

+ @if (!string.IsNullOrWhiteSpace(Subtitle)) + { +

@Subtitle

+ } +
+ @if (!string.IsNullOrWhiteSpace(Value)) + { +
+
@Value
+ @if (!string.IsNullOrWhiteSpace(ValueLabel)) + { +
@ValueLabel
+ } +
+ } +
+ +
+ @ChildContent +
+ + @if (!string.IsNullOrWhiteSpace(Insight)) + { +
+ @Insight +
+ } +
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public string? ValueLabel { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public string CardClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-5 shadow-sm"; + [Parameter] public string BodyClass { get; set; } = "mt-5"; + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs new file mode 100644 index 0000000000000000000000000000000000000000..44551c84c9de89280cd04d85d63db9c3d57e0fc7 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportChartModels.cs @@ -0,0 +1,13 @@ +namespace TaskTrackingSystem.WebApp.Components.Pages.Features.Reports.Components; + +public sealed record ReportChartItem(string Label, double Value, string Color = "#7c3aed", string? Detail = null); + +public sealed record StackedSegment(string Label, double Value, string Color, string? Detail = null); + +public sealed record HeatmapCell(string Row, string Column, double Value, string? Detail = null); + +public sealed record RiskMatrixPoint(string Label, int Severity, int Urgency, double Value, string Color = "#7c3aed", string? Detail = null); + +public sealed record TopRiskItem(string Title, string Meta, double Score, string Badge, string Tone = "slate", string? Detail = null); + +public sealed record SparklinePoint(string Label, double Value); diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportFilterPanel.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportFilterPanel.razor new file mode 100644 index 0000000000000000000000000000000000000000..d4e89c776ba3cc6ee889ddf74880c439aa2171fb --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportFilterPanel.razor @@ -0,0 +1,8 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public string PanelClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-6 shadow-sm"; + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportKpiGrid.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportKpiGrid.razor new file mode 100644 index 0000000000000000000000000000000000000000..78ce8351d415536bf0bf6c55fce6c87837d6e0eb --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportKpiGrid.razor @@ -0,0 +1,20 @@ +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components + +
+ @foreach (var metric in Metrics) + { + + } +
+ +@code { + [Parameter] public IReadOnlyList Metrics { get; set; } = Array.Empty(); + [Parameter] public string GridClass { get; set; } = "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"; + [Parameter] public string CardClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-4 text-slate-900 shadow-sm shadow-slate-200/50"; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportToolbar.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportToolbar.razor new file mode 100644 index 0000000000000000000000000000000000000000..203d72cb3779f17e3f9aaad1d4a469e6bdea6492 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/ReportToolbar.razor @@ -0,0 +1,26 @@ +
+
+ + +
+ + +
+ +@code { + [Parameter] public bool IsChartView { get; set; } + [Parameter] public EventCallback OnViewChanged { get; set; } + [Parameter] public EventCallback OnExportExcel { get; set; } + [Parameter] public EventCallback OnExportPdf { get; set; } + + private string ListButtonClass => $"inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold {(!IsChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900")} transition-colors"; + private string ChartButtonClass => $"inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold {(IsChartView ? "bg-violet-600 text-white" : "text-slate-600 hover:text-slate-900")} transition-colors"; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..3a46ef57bb834323664e516acd6d338b8223d49f --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor @@ -0,0 +1,129 @@ + + @if (!Points.Any()) + { +
@EmptyText
+ } + else + { +
+
+
+ @foreach (var label in UrgencyLabels) + { +
@label
+ } +
+ +
+
+ @SeverityAxisLabel +
+ +
+ +
+
+ @foreach (var label in SeverityLabels) + { +
@label
+ } +
+ +
+ Legend + Healthy + Watch + At risk + Critical +
+
+ } +
+ +@code { + [Parameter] public string Title { get; set; } = "Risk matrix"; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList Points { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No matrix data available."; + [Parameter] public string SeverityAxisLabel { get; set; } = "Risk severity"; + [Parameter] public IReadOnlyList UrgencyLabels { get; set; } = new[] { "Low urgency", "Medium", "High urgency" }; + [Parameter] public IReadOnlyList SeverityLabels { get; set; } = new[] { "Low risk", "Medium", "High risk" }; + + private static string GetCellLabel(int severity, int urgency, double total) + { + if (total <= 0) + { + return "No projects"; + } + + return (severity, urgency) switch + { + (3, 3) => "Critical", + (3, _) or (_, 3) => "At risk", + (1, 1) => "Healthy", + _ => "Watch" + }; + } + + private static string GetCellClass(int severity, int urgency, double total) + { + if (total <= 0) + { + return "flex min-h-[72px] flex-col justify-center rounded-lg border border-slate-100 bg-white p-3 text-center transition-colors hover:border-slate-200"; + } + + var score = severity * urgency; + var tone = score >= 9 + ? "border-rose-200 bg-rose-50" + : score >= 6 + ? "border-amber-200 bg-amber-50" + : score >= 3 + ? "border-blue-100 bg-blue-50" + : "border-emerald-100 bg-emerald-50"; + + return $"flex min-h-[72px] flex-col justify-center rounded-lg border p-3 text-center transition-colors hover:border-slate-300 hover:bg-white {tone}"; + } + + private static string GetCountClass(double total) => total > 0 + ? "text-2xl font-bold tabular-nums text-slate-950" + : "text-xl font-bold tabular-nums text-slate-300"; + + private static string GetLabelClass(double total) => total > 0 + ? "mt-1 text-[11px] font-semibold uppercase tracking-wider text-slate-600" + : "mt-1 text-[11px] font-medium text-slate-400"; + + private static string BuildCellSummary(IReadOnlyList points) + { + return string.Join(", ", points.OrderByDescending(point => point.Value).Take(2).Select(point => point.Label)); + } + + private static string BuildCellTitle(IReadOnlyList points, int severity, int urgency, double total) + { + if (total <= 0) + { + return $"No projects. Severity {severity}, urgency {urgency}."; + } + + var names = string.Join(Environment.NewLine, points.OrderByDescending(point => point.Value).Select(point => $"{point.Label}: {point.Detail ?? $"{point.Value:0} item(s)"}")); + return $"{total:0} project(s){Environment.NewLine}{names}"; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor new file mode 100644 index 0000000000000000000000000000000000000000..cf590d019aff27edbe1546af38b67dd664592cc1 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor @@ -0,0 +1,36 @@ +
+ @if (!Segments.Any() || Total <= 0) + { +
@EmptyText
+ } + else + { + +
+ @foreach (var segment in Segments) + { + var pct = segment.Value / Total * 100; +
+ + + @segment.Label + + @segment.Value.ToString("0.#") · @pct.ToString("0")% +
+ } +
+ } +
+ +@code { + [Parameter] public IReadOnlyList Segments { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No data available."; + [Parameter] public string AriaLabel { get; set; } = "Stacked progress"; + private double Total => Segments.Sum(segment => Math.Max(0, segment.Value)); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor new file mode 100644 index 0000000000000000000000000000000000000000..d87aa2de18eb4893eb0640255b0737d2d2e47393 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor @@ -0,0 +1,52 @@ +
+ @if (!Items.Any()) + { +
@EmptyText
+ } + else + { + @foreach (var item in Items.Take(Limit)) + { +
+
+
+
@item.Title
+
@item.Meta
+
+
@item.Badge
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(item.Detail)) + { +

@item.Detail

+ } +
+ } + } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public int Limit { get; set; } = 5; + [Parameter] public string EmptyText { get; set; } = "No risk items to show."; + + private static string GetBadgeClass(string tone) => tone switch + { + "rose" => "bg-rose-50 text-rose-700", + "amber" => "bg-amber-50 text-amber-700", + "emerald" => "bg-emerald-50 text-emerald-700", + "blue" => "bg-blue-50 text-blue-700", + _ => "bg-slate-100 text-slate-700" + }; + + private static string GetBarClass(string tone) => tone switch + { + "rose" => "bg-rose-500", + "amber" => "bg-amber-500", + "emerald" => "bg-emerald-500", + "blue" => "bg-blue-500", + _ => "bg-violet-600" + }; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..a6687cd76fea43e8c8e09e425c764b158a81c3fc --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor @@ -0,0 +1,50 @@ + + @if (Points.Count < 2) + { +
@EmptyText
+ } + else + { + + + + @foreach (var point in SvgPoints) + { + + } + +
+ @Points.First().Label + @Points.Last().Label +
+ } +
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public string? ValueLabel { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList Points { get; set; } = Array.Empty(); + [Parameter] public string Color { get; set; } = "#7c3aed"; + [Parameter] public string EmptyText { get; set; } = "Not enough data for a trend yet."; + + private IReadOnlyList<(double X, double Y)> SvgPoints => BuildSvgPoints(); + private string LinePath => SvgPoints.Any() ? $"M {string.Join(" L ", SvgPoints.Select(p => $"{p.X:0},{p.Y:0}"))}" : string.Empty; + private string AreaPath => SvgPoints.Any() ? $"{LinePath} L {SvgPoints.Last().X:0},112 L {SvgPoints.First().X:0},112 Z" : string.Empty; + + private IReadOnlyList<(double X, double Y)> BuildSvgPoints() + { + var max = Math.Max(1, Points.Max(point => point.Value)); + var min = Math.Min(0, Points.Min(point => point.Value)); + var range = Math.Max(1, max - min); + + return Points.Select((point, index) => + { + var x = 16 + index * (288d / Math.Max(1, Points.Count - 1)); + var y = 104 - ((point.Value - min) / range * 88); + return (x, y); + }).ToList(); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor new file mode 100644 index 0000000000000000000000000000000000000000..a87841674fc3ddbbbd78ef2a7b6e0ce63c556c37 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor @@ -0,0 +1,55 @@ + + @if (!Rows.Any() || !Columns.Any()) + { +
@EmptyText
+ } + else + { +
+
+
+
+ @foreach (var column in Columns) + { +
@column
+ } + @foreach (var row in Rows) + { +
@row
+ @foreach (var column in Columns) + { + var cell = Cells.FirstOrDefault(item => item.Row == row && item.Column == column); + var value = cell?.Value ?? 0; +
+ @value.ToString("0.#") +
+ } + } +
+
+
+ } +
+ +@code { + [Parameter] public string Title { get; set; } = "Workload heatmap"; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList Rows { get; set; } = Array.Empty(); + [Parameter] public IReadOnlyList Columns { get; set; } = Array.Empty(); + [Parameter] public IReadOnlyList Cells { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No workload data available."; + + private static string GetCellClass(double value) + { + var tone = value >= 75 + ? "border-rose-200 bg-rose-50 text-rose-700" + : value >= 45 + ? "border-amber-200 bg-amber-50 text-amber-700" + : value > 0 + ? "border-blue-100 bg-blue-50 text-blue-700" + : "border-slate-100 bg-white text-slate-300"; + + return $"flex h-12 items-center justify-center rounded-lg border text-center {tone}"; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..db409247a50d5db9029627c2a72c71bf68285974 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor @@ -0,0 +1,480 @@ +@page "/reports/employees" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.Report +@using System.Text +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS + +@AppLocalization.Text("report.employeeWorkloadTitle", "Employee Workload & Performance") + +
+ +
+
+

@AppLocalization.Text("report.employeeWorkloadHeading", "Employee Workload & Performance")

+

@AppLocalization.Text("report.employeeWorkloadDesc", "Monitor issue workload, execution health, and actual effort.")

+
+
+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + @if (filterPeriod == "custom") + { +
+ + +
+
+ + +
+ } + +
+ + +
+ + + +
+ + +
+
+
+ + + @if (isLoading) + { +
+
+ + @AppLocalization.Text("common.loading", "Loading data...") +
+
+ } + else + { + @if (isChartView) + { +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ } + else + { + +
+ + + + + + + + + + + + + + @if (pagedEmployees.Any()) + { + @foreach (var entry in pagedEmployees.Select((emp, index) => (emp, index))) + { + var emp = entry.emp; + + + + + + + + + + } + } + else + { + + + + } + +
No.EmployeeAssigned IssuesOpen / OverdueActual HoursWorkloadTrend
@((currentPage - 1) * pageSize + entry.index + 1) +
+ + @(emp.FullName.Length > 0 ? emp.FullName[0].ToString().ToUpper() : "U") + +
+

@emp.FullName

+

@@@emp.Username

+
+
+
@emp.AssignedIssues@emp.OpenIssues / @emp.OverdueIssues@emp.ActualHours.ToString("N1") hrs@emp.WorkloadLevel@emp.Trend
+

No employee data found matching criteria.

+
+ + + +
+ } + } +
+ +@code { + private bool isLoading = true; + private bool isChartView = false; + private List allEmployees = new(); + private List pagedEmployees = new(); + private PagedResult? reportPage; + + // Filters + private string filterPeriod = "all"; + private DateTime? filterStartDate; + private DateTime? filterEndDate; + private string filterStatus = "0"; + private bool filterAssignToMe = false; + private bool filterAssignToMyTeam = false; + + private List EmployeeHeatmapRows => allEmployees.Take(6).Select(emp => emp.FullName).ToList(); + private List EmployeeHeatmapColumns => new() { "Assigned", "Open", "Overdue", "Hours" }; + private int CompletedIssueTotal => allEmployees.Sum(emp => emp.CompletedCount); + private double AverageCompletionRate => allEmployees.Any() ? allEmployees.Average(emp => emp.CompletionRate) : 0; + + private List EmployeeHeatmapCells => allEmployees.Take(6) + .SelectMany(emp => new[] + { + new HeatmapCell(emp.FullName, "Assigned", Math.Min(100, emp.AssignedCount * 8), $"{emp.AssignedCount} assigned tasks"), + new HeatmapCell(emp.FullName, "Open", Math.Min(100, emp.OpenIssues * 12), $"{emp.OpenIssues} open issues"), + new HeatmapCell(emp.FullName, "Overdue", Math.Min(100, emp.OverdueIssues * 20), $"{emp.OverdueIssues} overdue issues"), + new HeatmapCell(emp.FullName, "Hours", Math.Min(100, (double)emp.ActualHours * 5), $"{emp.ActualHours:N1} actual hours") + }) + .ToList(); + + private List EmployeeCapacityRiskItems => allEmployees + .Select(emp => + { + var score = Math.Min(100, (emp.OpenIssues * 14) + (emp.OverdueIssues * 24) + (emp.AssignedCount * 4) + (double)emp.ActualHours); + var tone = score >= 70 ? "rose" : score >= 45 ? "amber" : "blue"; + return new TopRiskItem(emp.FullName, $"{emp.WorkloadLevel} · {emp.ActualHours:N1}h tracked", score, "needs support", tone, $"{emp.OpenIssues} open · {emp.OverdueIssues} overdue"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List EmployeeCompletionSegments => new() + { + new("Completed tasks", allEmployees.Sum(emp => emp.CompletedCount), "#10b981"), + new("Assigned tasks", allEmployees.Sum(emp => Math.Max(0, emp.AssignedCount - emp.CompletedCount)), "#3b82f6"), + new("Open issues", allEmployees.Sum(emp => emp.OpenIssues), "#f59e0b") + }; + + private List EmployeeOverdueItems => allEmployees + .Where(emp => emp.OverdueIssues > 0) + .OrderByDescending(emp => emp.OverdueIssues) + .Take(5) + .Select(emp => new TopRiskItem(emp.FullName, $"{emp.OpenIssues} open issues", Math.Min(100, emp.OverdueIssues * 25), $"{emp.OverdueIssues} overdue", "rose", "Capacity risk")) + .ToList(); + + private List EmployeeCompletionSparkline => allEmployees + .OrderByDescending(emp => emp.AssignedCount) + .Take(8) + .Select(emp => new SparklinePoint(BuildShortName(emp.FullName), emp.CompletionRate)) + .DefaultIfEmpty(new SparklinePoint("No data", 0)) + .ToList(); + + // Search and Pagination + private string searchInput = ""; + private string searchQuery = ""; + private int currentPage = 1; + private int pageSize = 10; + + private int TotalPages => reportPage?.TotalPages ?? 0; + private int TotalCount => reportPage?.TotalCount ?? 0; + + protected override async Task OnInitializedAsync() + { + await LoadEmployeeReportData(); + isLoading = false; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private void SetView(bool chartView) + { + isChartView = chartView; + } + + private async Task ApplyAllFilters() + { + ApplyPeriodPreset(); + searchQuery = searchInput; + currentPage = 1; + await LoadEmployeeReportData(); + } + + private async Task ResetAllFilters() + { + searchInput = ""; + searchQuery = ""; + filterPeriod = "all"; + filterStartDate = null; + filterEndDate = null; + filterStatus = "0"; + filterAssignToMe = false; + filterAssignToMyTeam = false; + currentPage = 1; + await LoadEmployeeReportData(); + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadEmployeeReportData(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadEmployeeReportData(); + } + + private void ApplyPeriodPreset() + { + var today = DateTime.Today; + if (filterPeriod == "daily") + { + filterStartDate = today; + filterEndDate = today; + } + else if (filterPeriod == "monthly") + { + filterStartDate = new DateTime(today.Year, today.Month, 1); + filterEndDate = new DateTime(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month)); + } + else if (filterPeriod == "yearly") + { + filterStartDate = new DateTime(today.Year, 1, 1); + filterEndDate = new DateTime(today.Year, 12, 31); + } + else + { + filterStartDate = null; + filterEndDate = null; + } + } + + private async Task LoadEmployeeReportData() + { + var client = ApiClient.CreateClient(); + try + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(searchQuery)) parts.Add($"search={Uri.EscapeDataString(searchQuery)}"); + if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}"); + if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}"); + if (!string.IsNullOrWhiteSpace(filterStatus) && filterStatus != "0") parts.Add($"status={Uri.EscapeDataString(filterStatus)}"); + if (filterAssignToMe) parts.Add("assignedToMe=true"); + if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true"); + + // 1. Fetch full matching list for chart view and exports + var fullUrl = "Report/employee-productivity" + (parts.Count > 0 ? "?" + string.Join("&", parts) : ""); + var fullResult = await client.GetFromJsonAsync>>(fullUrl, Serialization.CaseInsensitive); + if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null) + { + allEmployees = fullResult.Value; + } + + // 2. Fetch paged list for table pagination + var pagedParts = new List(parts); + pagedParts.Add($"page={currentPage}"); + pagedParts.Add($"limit={pageSize}"); + var pagedUrl = "Report/employee-productivity" + (pagedParts.Count > 0 ? "?" + string.Join("&", pagedParts) : ""); + var pagedResult = await client.GetFromJsonAsync>(pagedUrl, Serialization.CaseInsensitive); + if (pagedResult != null) + { + reportPage = pagedResult; + pagedEmployees = pagedResult.Items.ToList(); + currentPage = pagedResult.Page; + pageSize = pagedResult.PageSize; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading employee report: {ex.Message}"); + } + } + + private async Task DownloadExcel() + { + var rows = allEmployees.Select(emp => new object?[] + { + emp.FullName, + emp.Username, + emp.AssignedIssues, + emp.OpenIssues, + emp.OverdueIssues, + emp.ActualHours, + emp.WorkloadLevel, + emp.Trend + }); + + var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook( + "Employee Workload", + "Employee Workload & Performance Report", + new[] { "Employee", "Username", "Assigned Issues", "Open Issues", "Overdue Issues", "Actual Hours", "Workload", "Trend" }, + rows, + new[] + { + $"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All employees" : searchQuery)}", + $"Period: {filterPeriod}", + $"Task Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Convert.ToBase64String(workbookBytes)); + } + + private async Task DownloadPdf() + { + var summaryLines = new List + { + $"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All employees" : searchQuery)}", + $"Period: {filterPeriod}", + $"Task Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = allEmployees.Select(emp => new[] + { + emp.FullName, + emp.Username, + emp.AssignedIssues.ToString(), + emp.OpenIssues.ToString(), + emp.OverdueIssues.ToString(), + emp.ActualHours.ToString("N1"), + emp.WorkloadLevel, + emp.Trend + }); + + var pdfBytes = SimplePdfReportBuilder.BuildTableReport( + "Employee Workload & Performance Report", + summaryLines, + new[] { "Employee", "Username", "Assigned Issues", "Open", "Overdue", "Hours", "Workload", "Trend" }, + rows); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"EmployeeWorkloadPerformance_{DateTime.Today:yyyyMMdd}.pdf", + "application/pdf", + Convert.ToBase64String(pdfBytes)); + } + + private static string GetWorkloadBadge(string workload) => workload switch + { + "High" => "bg-rose-50 text-rose-700", + "Light" => "bg-slate-100 text-slate-600", + _ => "bg-emerald-50 text-emerald-700" + }; + + private static string GetTrendBadge(string trend) => trend switch + { + "Improving" => "bg-emerald-50 text-emerald-700", + "Declining" => "bg-rose-50 text-rose-700", + _ => "bg-blue-50 text-blue-700" + }; + + private static string BuildShortName(string fullName) + { + var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 0 ? "User" : parts.Length == 1 ? parts[0] : $"{parts[0]} {parts[^1][0]}."; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..5365b08e870b052c72db28ad43c4a30e50ff9e4e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor @@ -0,0 +1,412 @@ +@page "/reports/issues" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.Shared.Models.Issue +@using TaskTrackingSystem.Shared.Models.Project +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS + +@AppLocalization.PageTitle("issueReport", "Issue Report") + +
+
+
+

@AppLocalization.PageTitle("issueReport", "Issue Report")

+

@AppLocalization.PageDescription("issueReport", "Track daily execution, backlog health, and issue-level effort.")

+
+
+
+ + +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + @if (isLoading) + { + + } + else + { +
+ @MetricCard(AppLocalization.Text("report.totalIssues", "Total Issues"), FilteredIssues.Count().ToString(), "file-pen-line", "violet") + @MetricCard(AppLocalization.Text("report.openIssues", "Open Issues"), OpenIssues.ToString(), "list-todo", "blue") + @MetricCard(AppLocalization.Text("report.overdueIssues", "Overdue Issues"), OverdueIssues.ToString(), "alarm-clock", "rose") + @MetricCard(AppLocalization.Text("report.blockedIssues", "Blocked Issues"), BlockedIssues.ToString(), "ban", "amber") +
+ +
+

@AppLocalization.Text("report.keyInsights", "Key Insights")

+
+ @foreach (var insight in Insights) + { +
@insight
+ } +
+
+ +
+ @if (isChartView) + { +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ } + else + { +
+ + + + + + + + + + + + + + + + + + + + + + + @foreach (var issue in PagedIssues) + { + + + + + + + + + + } + @if (!PagedIssues.Any()) + { + + } + +
@AppLocalization.Text("common.issue", "Issue")@AppLocalization.Text("common.taskProject", "Task / Project")@AppLocalization.Text("common.assignee", "Assignee")@AppLocalization.Text("common.status", "Status")@AppLocalization.Text("common.hours", "Hours")@AppLocalization.Text("common.dueDate", "Due")@AppLocalization.Text("common.risk", "Risk")
+ + @if (expandedIssues.Contains(issue.Id)) + { +
+
@(string.IsNullOrWhiteSpace(issue.Description) ? AppLocalization.Text("common.noDescription", "No description") : issue.Description)
+
@AppLocalization.Text("report.delay", "Delay"): @(string.IsNullOrWhiteSpace(issue.DelayReason) ? AppLocalization.Text("common.noneRecorded", "None recorded") : issue.DelayReason)
+
@AppLocalization.Text("report.blockedBy", "Blocked by"): @(string.IsNullOrWhiteSpace(issue.BlockedBy) ? AppLocalization.Text("common.none", "None") : issue.BlockedBy)
+
+ } +
+ + @if (expandedTaskDetails.Contains(issue.Id)) + { +
+
@issue.TaskTitle
+
@issue.ProjectName
+
+ } +
@(issue.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"))@GetStatusLabel(issue.StatusId)@((issue.EstimatedHours ?? 0).ToString("N1")) / @((issue.ActualHours ?? 0).ToString("N1"))@DisplayFormats.Date(issue.DueDate)@RiskBadge(issue)
+
+ + } +
+ } +
+ +@code { + private bool isLoading = true; + private bool isChartView; + private List issues = new(); + private List projects = new(); + private HashSet expandedIssues = new(); + private HashSet expandedTaskDetails = new(); + private string searchInput = string.Empty; + private string appliedSearch = string.Empty; + private long filterProjectId; + private long appliedProjectId; + private string filterStatus = "0"; + private string appliedStatus = "0"; + private int currentPage = 1; + private int pageSize = 10; + private const string StorageKey = "tts.report.issues.filters"; + + private IEnumerable FilteredIssues => issues.Where(MatchesFilters).OrderBy(i => i.DueDate).ThenBy(i => i.Title); + private IEnumerable PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize); + private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize); + private int OpenIssues => FilteredIssues.Count(i => i.StatusId != AppTaskStatus.Done); + private int OverdueIssues => FilteredIssues.Count(IsOverdue); + private int BlockedIssues => FilteredIssues.Count(i => i.IsBlocked); + + protected override async Task OnInitializedAsync() + { + var client = ApiClient.CreateClient(); + try + { + var issuesTask = client.GetFromJsonAsync>>("Report/issues", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + await Task.WhenAll(issuesTask, projectsTask); + issues = issuesTask.Result?.Value ?? new(); + projects = projectsTask.Result ?? new(); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + if (firstRender) + { + await LoadSavedFilters(); + } + } + + private bool MatchesFilters(IssueDto issue) + { + if (appliedProjectId > 0 && issue.ProjectId != appliedProjectId) return false; + if (appliedStatus == "overdue" && !IsOverdue(issue)) return false; + if (appliedStatus == "blocked" && !issue.IsBlocked) return false; + if (int.TryParse(appliedStatus, out var statusValue) && statusValue > 0 && (int)issue.StatusId != statusValue) return false; + if (!string.IsNullOrWhiteSpace(appliedSearch)) + { + var s = appliedSearch.Trim(); + return issue.Title.Contains(s, StringComparison.OrdinalIgnoreCase) + || issue.TaskTitle.Contains(s, StringComparison.OrdinalIgnoreCase) + || issue.ProjectName.Contains(s, StringComparison.OrdinalIgnoreCase) + || (issue.AssignedToName?.Contains(s, StringComparison.OrdinalIgnoreCase) == true); + } + return true; + } + + private async Task ApplyFilters() + { + appliedSearch = searchInput; + appliedProjectId = filterProjectId; + appliedStatus = filterStatus; + currentPage = 1; + await SaveFilters(); + } + + private async Task ResetFilters() + { + searchInput = appliedSearch = string.Empty; + filterProjectId = appliedProjectId = 0; + filterStatus = appliedStatus = "0"; + currentPage = 1; + await SaveFilters(); + } + + private void SetView(bool chartView) => isChartView = chartView; + private void ToggleExpanded(long id) { if (!expandedIssues.Remove(id)) expandedIssues.Add(id); } + private void ToggleTaskDetails(long id) { if (!expandedTaskDetails.Remove(id)) expandedTaskDetails.Add(id); } + private Task HandlePageChanged(int page) { currentPage = page; return Task.CompletedTask; } + private Task HandlePageSizeChanged(int size) { pageSize = size; currentPage = 1; return Task.CompletedTask; } + + private async Task SaveFilters() + { + await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, System.Text.Json.JsonSerializer.Serialize(new SavedFilter(appliedSearch, appliedProjectId, appliedStatus))); + } + + private async Task LoadSavedFilters() + { + var json = await JS.InvokeAsync("localStorage.getItem", StorageKey); + if (string.IsNullOrWhiteSpace(json)) return; + var saved = System.Text.Json.JsonSerializer.Deserialize(json); + if (saved == null) return; + searchInput = appliedSearch = saved.Search; + filterProjectId = appliedProjectId = saved.ProjectId; + filterStatus = appliedStatus = saved.Status; + StateHasChanged(); + } + + private static bool IsOverdue(IssueDto issue) => issue.StatusId != AppTaskStatus.Done && issue.DueDate.Date < DateTime.Today; + private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}"; + private static string GetStatusLabel(AppTaskStatus status) => AppLocalization.StatusLabel(status); + private static string GetStatusBadge(AppTaskStatus status) => status == AppTaskStatus.Done ? "bg-emerald-50 text-emerald-700" : status == AppTaskStatus.InProgress ? "bg-blue-50 text-blue-700" : "bg-slate-100 text-slate-700"; + private static string GetPriorityLabel(TaskPriority priority) => AppLocalization.PriorityLabel(priority); + private static string EscalationLabel(int level) => level switch { 1 => AppLocalization.Text("report.teamLead", "Team Lead"), 2 => AppLocalization.Text("report.pm", "PM"), 3 => AppLocalization.Text("report.manager", "Manager"), _ => AppLocalization.Text("common.none", "None") }; + + private RenderFragment RiskBadge(IssueDto issue) => builder => + { + var text = issue.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : issue.EscalationLevel > 0 ? EscalationLabel(issue.EscalationLevel) : IsOverdue(issue) ? AppLocalization.Text("status.overdue", "Overdue") : AppLocalization.Text("status.clear", "Clear"); + var cls = issue.IsBlocked || IsOverdue(issue) ? "bg-rose-50 text-rose-700" : issue.EscalationLevel > 0 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"; + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", $"rounded-full px-2 py-0.5 text-xs font-semibold {cls}"); + builder.AddContent(2, text); + builder.CloseElement(); + }; + + private IReadOnlyList<(string Label, int Value)> StatusChart => new[] { (AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo)), (AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress)), (AppLocalization.StatusLabel(AppTaskStatus.Done), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done)), (AppLocalization.Text("status.overdue", "Overdue"), OverdueIssues) }; + private IReadOnlyList<(string Label, int Value)> PriorityChart => Enum.GetValues().Select(p => (GetPriorityLabel(p), FilteredIssues.Count(i => i.PriorityId == p))).ToList(); + private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned")).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); + private IReadOnlyList<(string Label, int Value)> ProjectChart => FilteredIssues.GroupBy(i => i.ProjectName).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); + private IEnumerable Insights => new[] { $"{OverdueIssues} {AppLocalization.Text("report.overdueIssuesNeedAttention", "overdue issues need attention.")}", $"{BlockedIssues} {AppLocalization.Text("report.issuesAreBlocked", "issues are blocked.")}", $"{AppLocalization.Text("report.actualHoursAre", "Actual hours are")} {FilteredIssues.Sum(i => i.ActualHours ?? 0):N1} {AppLocalization.Text("report.against", "against")} {FilteredIssues.Sum(i => i.EstimatedHours ?? 0):N1} {AppLocalization.Text("report.estimated", "estimated.")}" }; + private double BlockedOverduePercent => FilteredIssues.Any() ? (BlockedIssues + OverdueIssues) / (double)FilteredIssues.Count() * 100 : 0; + private string IssueStatusInsight => OpenIssues > FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done) + ? $"{OpenIssues} {AppLocalization.Text("report.issuesStillOpen", "issues are still open across the selected scope.")}" + : AppLocalization.Text("report.resolvedIssuesLeading", "Resolved issues are leading this filtered set."); + + private List IssueFunnelItems => new() + { + new(AppLocalization.Text("report.created", "Created"), FilteredIssues.Count(), "#7c3aed", AppLocalization.Text("report.allReportedIssues", "All reported issues")), + new(AppLocalization.Text("report.open", "Open"), OpenIssues, "#3b82f6", AppLocalization.Text("report.unresolvedIssues", "Unresolved issues")), + new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#06b6d4", AppLocalization.Text("report.currentlyBeingWorked", "Currently being worked")), + new(AppLocalization.Text("status.blocked", "Blocked"), BlockedIssues, "#f59e0b", AppLocalization.Text("report.needsUnblockReview", "Needs unblock review")), + new(AppLocalization.Text("status.resolved", "Resolved"), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981", AppLocalization.Text("report.completedIssues", "Completed issues")) + }; + + private List IssueStatusItems => new() + { + new(AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Todo), "#94a3b8"), + new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.InProgress), "#3b82f6"), + new(AppLocalization.StatusLabel(AppTaskStatus.Done), FilteredIssues.Count(i => i.StatusId == AppTaskStatus.Done), "#10b981"), + new(AppLocalization.Text("status.overdue", "Overdue"), OverdueIssues, "#ef4444") + }; + + private List IssueProjectItems => FilteredIssues + .GroupBy(i => i.ProjectName) + .Select(g => + { + var blocked = g.Count(i => i.IsBlocked); + var overdue = g.Count(IsOverdue); + var score = Math.Min(100, (g.Count() * 10) + (blocked * 25) + (overdue * 20)); + return new TopRiskItem(g.Key, $"{g.Count()} {AppLocalization.Text("common.issue", "issues")} · {blocked} {AppLocalization.Text("status.blocked", "blocked")}", score, overdue > 0 ? $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}" : $"{g.Count()} {AppLocalization.Text("common.issue", "issues")}", overdue > 0 || blocked > 0 ? "rose" : "blue"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List IssueAssigneeItems => FilteredIssues + .GroupBy(i => i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned")) + .Select(g => + { + var blocked = g.Count(i => i.IsBlocked); + var overdue = g.Count(IsOverdue); + var score = Math.Min(100, (g.Count() * 12) + (blocked * 22) + (overdue * 18)); + return new TopRiskItem(g.Key, $"{g.Count()} {AppLocalization.Text("report.assignedIssues", "assigned issues")}", score, blocked > 0 ? $"{blocked} {AppLocalization.Text("status.blocked", "blocked")}" : $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}", blocked > 0 || overdue > 0 ? "amber" : "blue", AppLocalization.Text("report.supportSignal", "Support signal")); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List IssueAgingBuckets => new() + { + new(AppLocalization.Text("report.dueLater", "Due later"), FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date > DateTime.Today.AddDays(3)), "#3b82f6"), + new(AppLocalization.Text("report.dueSoon", "Due soon"), FilteredIssues.Count(i => !IsOverdue(i) && i.DueDate.Date <= DateTime.Today.AddDays(3)), "#f59e0b"), + new(AppLocalization.Text("report.1to7Late", "1-7d late"), FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days <= 7), "#fb7185"), + new(AppLocalization.Text("report.8PlusLate", "8d+ late"), FilteredIssues.Count(i => IsOverdue(i) && (DateTime.Today - i.DueDate.Date).Days > 7), "#ef4444") + }; + + private RenderFragment MetricCard(string label, string value, string icon, string color) => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm"); + builder.AddMarkupContent(2, $"

{label}

{value}

"); + builder.CloseElement(); + }; + + private async Task DownloadExcel() + { + var rows = FilteredIssues.Select(i => new object?[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"), GetStatusLabel(i.StatusId), GetPriorityLabel(i.PriorityId), i.EstimatedHours ?? 0, i.ActualHours ?? 0, i.DueDate, i.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : AppLocalization.Text("status.clear", "Clear"), EscalationLabel(i.EscalationLevel), i.DelayReason ?? "" }); + var bytes = SpreadsheetReportBuilder.BuildTableWorkbook(AppLocalization.PageTitle("issueReport", "Issue Report"), AppLocalization.PageTitle("issueReport", "Issue Report"), new[] { AppLocalization.Text("common.issue", "Issue"), AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.assignee", "Assignee"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("report.estimated", "Estimated"), AppLocalization.Text("report.actual", "Actual"), AppLocalization.Text("common.dueDate", "Due Date"), AppLocalization.Text("status.blocked", "Blocked"), AppLocalization.Text("report.escalation", "Escalation"), AppLocalization.Text("report.delayReason", "Delay Reason") }, rows, new[] { $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" }); + await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(bytes)); + } + + private async Task DownloadPdf() + { + var rows = FilteredIssues.Select(i => new[] { i.Title, i.TaskTitle, i.ProjectName, i.AssignedToName ?? AppLocalization.Text("common.unassigned", "Unassigned"), GetStatusLabel(i.StatusId), DisplayFormats.Date(i.DueDate), i.IsBlocked ? AppLocalization.Text("status.blocked", "Blocked") : AppLocalization.Text("status.clear", "Clear") }); + var bytes = SimplePdfReportBuilder.BuildTableReport(AppLocalization.PageTitle("issueReport", "Issue Report"), new[] { $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" }, new[] { AppLocalization.Text("common.issue", "Issue"), AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.assignee", "Assignee"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.dueDate", "Due Date"), AppLocalization.Text("common.issue", "Risk") }, rows); + await JS.InvokeVoidAsync("downloadFileFromBase64", $"IssueReport_{DateTime.Today:yyyyMMdd}.pdf", "application/pdf", Convert.ToBase64String(bytes)); + } + + private sealed record SavedFilter(string Search, long ProjectId, string Status); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..151ebde544ea91d4fecf4aea76926ca037d6a212 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor @@ -0,0 +1,534 @@ +@page "/reports/overdue" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Report +@using System.Text +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.Text("report.overdueTasksPageTitle", "Overdue & Delayed Tasks Report") + +
+ +
+
+

@AppLocalization.Text("report.overdueTasksHeading", "Overdue & Delayed Tasks")

+

@AppLocalization.Text("report.overdueTasksDesc", "Track tasks that have passed their due date or are at risk of delay.")

+
+
+
+ + +
+ + +
+
+ + + @if (!isLoading) + { +
+
+
+
+

Overdue Tasks

+

@overdueCount

+
+ + + +
+
+
+
+
+

@AppLocalization.Text("report.highPriorityOverdue", "High Priority Overdue")

+

@criticalCount

+
+ + + +
+
+
+
+
+

@AppLocalization.Text("report.dueWithin3Days", "Due Within 3 Days")

+

@dueSoonCount

+
+ + + +
+
+
+
+
+

@AppLocalization.Text("report.avgDaysOverdue", "Avg Days Overdue")

+

@avgDaysOverdue

+
+ + + +
+
+
+ } + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + +
+
+
+ + @if (isLoading) + { + + } + else + { + @if (isChartView) + { +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ } + else + { +
+
+

+ @AppLocalization.Text("report.showingDelayedTasks", "Showing") @(overdueReportPage?.TotalCount ?? 0) @AppLocalization.Text("report.delayedOrOverdueTasks", "delayed / overdue task(s)") +

+
+ + + + + + + + + + + + + + + @if (overdueReportTasks.Any()) + { + @foreach (var entry in overdueReportTasks.Select((task, index) => (task, index))) + { + var task = entry.task; + + + + + + + + + + + } + } + else + { + + } + +
@AppLocalization.Text("common.index", "No.")@AppLocalization.Text("common.task", "Task")@AppLocalization.Text("common.project", "Project")@AppLocalization.Text("common.assignee", "Assignee")@AppLocalization.Text("common.dueDate", "Due Date")@AppLocalization.Text("report.daysOverdue", "Days Overdue")@AppLocalization.Text("common.issue", "Issues")@AppLocalization.Text("report.delayEscalation", "Delay / Escalation")
@((currentPage - 1) * pageSize + entry.index + 1) +
+

@task.Title

+ @if (!string.IsNullOrEmpty(task.Description)) + { +

@task.Description

+ } +
+
+ + + @task.ProjectName + + + @if (!string.IsNullOrEmpty(task.AssignedTo)) + { +
+ + @(task.AssignedTo.Length > 0 ? task.AssignedTo[0].ToString().ToUpper() : "") + + @task.AssignedTo +
+ } + else + { + Unassigned + } +
+ + @DisplayFormats.Date(task.DueDate) + + + @if (task.DaysOverdue > 0) + { + + + @task.DaysOverdue day@(task.DaysOverdue != 1 ? "s" : "") + + } + else + { + var daysDue = Math.Abs(task.DaysOverdue); + + + Due in @daysDue day@(daysDue != 1 ? "s" : "") + + } + + @task.OverdueIssues overdue + +
@(string.IsNullOrWhiteSpace(task.DelayReason) ? "No reason recorded" : task.DelayReason)
+
Blocked by: @(string.IsNullOrWhiteSpace(task.BlockedBy) ? "None" : task.BlockedBy) · Escalation: @GetEscalationLabel(task.EscalationLevel)
+
+ + + +
+ } + } +
+ +@code { + private bool isLoading = true; + private bool isChartView = false; + private List allOverdue = new(); + private List overdueReportTasks = new(); + private PagedResult? overdueReportPage; + + // Filters + private string searchInput = ""; + private string appliedSearch = ""; + private int filterPriority = 0; + private int appliedPriority = 0; + private string filterDelayType = "all"; + private string appliedDelayType = "all"; + private bool filterAssignToMe = false; + private bool filterAssignToMyTeam = false; + + // Pagination + private int currentPage = 1; + private int pageSize = 10; + + // Summary stats + private int overdueCount = 0; + private int criticalCount = 0; + private int dueSoonCount = 0; + private int avgDaysOverdue = 0; + private bool canUpdateTask = false; + + private int TotalPages => overdueReportPage?.TotalPages ?? 0; + private double HighPriorityOverduePercent => overdueCount > 0 ? criticalCount / (double)overdueCount * 100 : 0; + private string AgingInsight => avgDaysOverdue > 7 + ? $"Average delay is {avgDaysOverdue} days; focus on older buckets first." + : "Most delayed work is still in a manageable window."; + + private List AgingBuckets => new() + { + new("Due soon", allOverdue.Count(t => t.DaysOverdue <= 0), "#f59e0b", "not overdue yet"), + new("1-3d", allOverdue.Count(t => t.DaysOverdue >= 1 && t.DaysOverdue <= 3), "#3b82f6", "early delay"), + new("4-7d", allOverdue.Count(t => t.DaysOverdue >= 4 && t.DaysOverdue <= 7), "#f59e0b", "needs action"), + new("8d+", allOverdue.Count(t => t.DaysOverdue >= 8), "#ef4444", "high risk") + }; + + private List DelayProjectItems => allOverdue + .GroupBy(t => t.ProjectName) + .Select(g => + { + var overdue = g.Count(t => t.DaysOverdue > 0); + var high = g.Count(t => t.PriorityName == "High"); + var score = Math.Min(100, (overdue * 18) + (high * 15) + g.Sum(t => Math.Max(0, t.DaysOverdue))); + return new TopRiskItem(g.Key, $"{overdue} overdue · {high} high priority", score, $"{g.Count()} tasks", score >= 70 ? "rose" : "amber", $"{g.Sum(t => t.OverdueIssues)} overdue issues"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List DelayAssigneeItems => allOverdue + .GroupBy(t => string.IsNullOrWhiteSpace(t.AssignedTo) ? "Unassigned" : t.AssignedTo!) + .Select(g => + { + var overdue = g.Count(t => t.DaysOverdue > 0); + var score = Math.Min(100, (overdue * 20) + g.Sum(t => Math.Max(0, t.DaysOverdue))); + return new TopRiskItem(g.Key, $"{overdue} overdue tasks", score, $"{score:0} risk", score >= 70 ? "rose" : "amber", "Capacity support may be needed"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List OverdueRiskMatrix => allOverdue + .GroupBy(t => new { Severity = GetPrioritySeverity(t.PriorityName), Urgency = GetOverdueUrgency(t.DaysOverdue), t.ProjectName }) + .Select(g => new RiskMatrixPoint(g.Key.ProjectName, g.Key.Severity, g.Key.Urgency, g.Count(), g.Key.Severity * g.Key.Urgency >= 9 ? "#ef4444" : "#f59e0b", $"{g.Count()} delayed task(s)")) + .ToList(); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + canUpdateTask = await MenuAuthorization.HasAccessCodeAsync(authState.User, "Tasks_Update"); + await LoadOverdueReport(); + isLoading = false; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadOverdueReport() + { + var client = ApiClient.CreateClient(); + try + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(appliedSearch)) parts.Add($"search={Uri.EscapeDataString(appliedSearch)}"); + if (appliedPriority > 0) parts.Add($"priorityId={appliedPriority}"); + if (!string.IsNullOrWhiteSpace(appliedDelayType)) parts.Add($"delayType={Uri.EscapeDataString(appliedDelayType)}"); + if (filterAssignToMe) parts.Add("assignedToMe=true"); + if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true"); + + // 1. Fetch full matching list once to compute summary cards and for exports + var fullUrl = "Report/overdue-critical" + (parts.Count > 0 ? "?" + string.Join("&", parts) : ""); + var fullResult = await client.GetFromJsonAsync>>(fullUrl, Serialization.CaseInsensitive); + if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null) + { + allOverdue = fullResult.Value; + var today = DateTime.Today; + var now = DateTime.UtcNow; + + overdueCount = allOverdue.Count(t => t.DueDate < now); + criticalCount = allOverdue.Count(t => t.PriorityName == "High" && t.DaysOverdue > 0); + dueSoonCount = allOverdue.Count(t => t.DueDate >= now && t.DueDate <= now.AddDays(3)); + avgDaysOverdue = overdueCount > 0 + ? (int)allOverdue.Where(t => t.DueDate < now).Average(t => t.DaysOverdue) + : 0; + } + + // 2. Fetch paged list for the table view + var pagedParts = new List(parts); + pagedParts.Add($"page={currentPage}"); + pagedParts.Add($"limit={pageSize}"); + var pagedUrl = "Report/overdue-critical" + (pagedParts.Count > 0 ? "?" + string.Join("&", pagedParts) : ""); + var pagedResult = await client.GetFromJsonAsync>(pagedUrl, Serialization.CaseInsensitive); + if (pagedResult != null) + { + overdueReportPage = pagedResult; + overdueReportTasks = pagedResult.Items.ToList(); + currentPage = pagedResult.Page; + pageSize = pagedResult.PageSize; + } + } + catch (Exception ex) + { + Console.WriteLine($"Overdue report loading error: {ex.Message}"); + } + } + + private async Task ApplyFilters() + { + appliedSearch = searchInput; + appliedPriority = filterPriority; + appliedDelayType = filterDelayType; + currentPage = 1; + await LoadOverdueReport(); + } + + private async Task ResetFilters() + { + searchInput = ""; + appliedSearch = ""; + filterPriority = 0; + appliedPriority = 0; + filterDelayType = "all"; + appliedDelayType = "all"; + filterAssignToMe = false; + filterAssignToMyTeam = false; + currentPage = 1; + await LoadOverdueReport(); + } + + private void SetView(bool chartView) + { + isChartView = chartView; + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadOverdueReport(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadOverdueReport(); + } + + private async Task DownloadExcel() + { + var client = ApiClient.CreateClient(); + try + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(appliedSearch)) parts.Add($"search={Uri.EscapeDataString(appliedSearch)}"); + if (appliedPriority > 0) parts.Add($"priorityId={appliedPriority}"); + + var url = "Report/overdue-critical/excel" + (parts.Count > 0 ? "?" + string.Join("&", parts) : ""); + var response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) + { + var bytes = await response.Content.ReadAsByteArrayAsync(); + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"OverdueTasksReport_{DateTime.Today:yyyyMMdd}.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Convert.ToBase64String(bytes)); + } + } + catch (Exception ex) { Console.WriteLine($"Excel download error: {ex.Message}"); } + } + + private async Task DownloadPdf() + { + var summaryLines = new List + { + $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All tasks" : appliedSearch)}", + $"Priority: {appliedPriority switch { 0 => "All", 1 => "Low", 2 => "Medium", 3 => "High", _ => appliedPriority.ToString() }}", + $"Delay Type: {appliedDelayType switch { "all" => "All delayed tasks", "overdue" => "Overdue", "soon" => "Due within 3 days", _ => appliedDelayType }}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = allOverdue.Select(task => + { + return new[] + { + task.Title, + task.ProjectName, + task.AssignedTo ?? "Unassigned", + DisplayFormats.Date(task.DueDate), + task.DaysOverdue.ToString(), + task.PriorityName, + task.StatusName + }; + }); + + var pdfBytes = SimplePdfReportBuilder.BuildTableReport( + "Overdue Tasks Report", + summaryLines, + new[] { "Task", "Project", "Assignee", "Due Date", "Days Overdue", "Priority", "Status" }, + rows); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"OverdueTasksReport_{DateTime.Today:yyyyMMdd}.pdf", + "application/pdf", + Convert.ToBase64String(pdfBytes)); + } + + private static string GetEscalationLabel(int level) => level switch + { + 1 => "Team Lead", + 2 => "PM", + 3 => "Manager", + _ => "None" + }; + + private static int GetPrioritySeverity(string priorityName) => priorityName switch + { + "High" => 3, + "Medium" => 2, + _ => 1 + }; + + private static int GetOverdueUrgency(int daysOverdue) => daysOverdue >= 8 ? 3 : daysOverdue >= 4 ? 2 : 1; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..b92dfc7052e9c4b6ac0b0f5b506b7ae3545ccd16 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor @@ -0,0 +1,602 @@ +@page "/reports/projects" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.Report +@using System.Text +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.Text("report.projectProgressTitle", "Project Progress Report") + +
+ +
+
+

@AppLocalization.Text("report.projectProgressHeading", "Project Progress")

+

@AppLocalization.Text("report.projectProgressDesc", "View project health using both planning tasks and daily execution issues.")

+
+
+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + @if (filterPeriod == "custom") + { +
+ + +
+
+ + +
+ } + +
+ + +
+ + + +
+ + +
+
+
+ + + @if (isLoading) + { +
+
+ + @AppLocalization.Text("common.loading", "Loading data...") +
+
+ } + else + { +
+ +
+
+
+

Total Active Projects

+

@totalActiveProjects

+
+ + + +
+
+ +
+
+
+

Ahead of Schedule

+

@aheadOfScheduleCount

+
+ + + +
+
+ +
+
+
+

At Risk

+

@atRiskCount

+
+ + + +
+
+
+ + @if (isChartView) + { +
+
+ +
+
+ + @if (!ProjectQuadrantPoints.Any()) + { +
No project risk data available.
+ } + else + { +
+
+
+ Risk +
+
+
+
+
Review now
+
Low completion · high risk
+
+
+
Watch closely
+
High completion · high risk
+
+
+
Recover plan
+
Low completion · low risk
+
+
+
On track
+
High completion · low risk
+
+
+ + @foreach (var point in ProjectQuadrantPoints) + { +
+
+ } +
+
+
+
+
+ 0% complete + Completion + 100% +
+
+
+ Color + Healthy + Watch + At risk + Critical +
+
+ } +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ } + else + { + +
+ + + + + + + + + + + + + + + @if (pagedProjects.Any()) + { + @foreach (var entry in pagedProjects.Select((proj, index) => (proj, index))) + { + var proj = entry.proj; + + + + + + + + + + + } + } + else + { + + + + } + +
No.Project NameStart DateEnd DateOpen IssuesBlocked / OverdueHealthLast Activity
@((currentPage - 1) * pageSize + entry.index + 1)@proj.ProjectName@DisplayFormats.Date(proj.StartDate)@DisplayFormats.Date(proj.EndDate)@proj.OpenIssues@proj.BlockedIssues / @proj.OverdueIssues@proj.ProjectHealth@DisplayFormats.Date(proj.LastActivity)
+

No project data found matching criteria.

+
+ + + +
+ } + } +
+ +@code { + private bool isLoading = true; + private bool isChartView = false; + private List allProjects = new(); + private List pagedProjects = new(); + private PagedResult? reportPage; + + // Filters + private string filterPeriod = "all"; + private DateTime? filterStartDate; + private DateTime? filterEndDate; + private string filterStatus = "0"; + private bool filterAssignToMe = false; + private bool filterAssignToMyTeam = false; + + private List ProjectHealthMatrix => allProjects + .Select(project => new RiskMatrixPoint( + project.ProjectName, + GetProjectSeverity(project), + GetProjectUrgency(project), + 1, + project.ProjectHealth == "Critical" ? "#ef4444" : project.IsAtRisk ? "#f59e0b" : "#3b82f6", + $"{project.Progress:0}% complete, {project.BlockedIssues} blocked, {project.OverdueIssues} overdue")) + .ToList(); + + private List ProjectQuadrantPoints => allProjects + .OrderByDescending(project => GetProjectRiskScore(project)) + .Take(10) + .Select(project => + { + var risk = GetProjectRiskScore(project); + var left = Math.Clamp(project.Progress, 2, 98); + var top = Math.Clamp(100 - risk, 2, 98); + var color = project.ProjectHealth == "Critical" + ? "#ef4444" + : project.IsAtRisk + ? "#f59e0b" + : risk > 35 ? "#3b82f6" : "#10b981"; + var tooltip = $"{project.ProjectName}\n{project.Progress:0}% complete\nRisk score: {risk:0}\n{project.BlockedIssues} blocked, {project.OverdueIssues} overdue, {project.OpenIssues} open"; + return new ProjectQuadrantPoint(project.ProjectName, left, top, color, tooltip); + }) + .ToList(); + + private List AtRiskProjectItems => allProjects + .Select(project => + { + var score = GetProjectRiskScore(project); + var tone = project.ProjectHealth == "Critical" || project.BlockedIssues > 0 ? "rose" : project.IsAtRisk ? "amber" : "blue"; + return new TopRiskItem(project.ProjectName, $"{project.Progress:0}% complete · {project.OpenIssues} open issues", score, project.ProjectHealth, tone, $"{project.OverdueIssues} overdue · {project.BlockedIssues} blocked"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List ProjectTimelineItems => allProjects + .OrderBy(project => project.StartDate) + .Take(8) + .Select(project => new TimelineChartItem(project.ProjectName, project.StartDate, project.EndDate, project.Progress, project.IsAtRisk ? "#f59e0b" : "#3b82f6")) + .ToList(); + + private List ProjectIssueSegments => new() + { + new("Open issues", allProjects.Sum(project => project.OpenIssues), "#3b82f6"), + new("Overdue issues", allProjects.Sum(project => project.OverdueIssues), "#ef4444"), + new("Blocked issues", allProjects.Sum(project => project.BlockedIssues), "#f59e0b") + }; + + // Search and Pagination + private string searchInput = ""; + private string searchQuery = ""; + private int currentPage = 1; + private int pageSize = 10; + + // Card stats + private int totalActiveProjects = 0; + private int aheadOfScheduleCount = 0; + private int atRiskCount = 0; + + private int TotalPages => reportPage?.TotalPages ?? 0; + private int TotalCount => reportPage?.TotalCount ?? 0; + + protected override async Task OnInitializedAsync() + { + await LoadProjectReportData(); + isLoading = false; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private void SetView(bool chartView) + { + isChartView = chartView; + } + + private async Task ApplyAllFilters() + { + ApplyPeriodPreset(); + searchQuery = searchInput; + currentPage = 1; + await LoadProjectReportData(); + } + + private async Task ResetAllFilters() + { + searchInput = ""; + searchQuery = ""; + filterPeriod = "all"; + filterStartDate = null; + filterEndDate = null; + filterStatus = "0"; + filterAssignToMe = false; + filterAssignToMyTeam = false; + currentPage = 1; + await LoadProjectReportData(); + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadProjectReportData(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadProjectReportData(); + } + + private void ApplyPeriodPreset() + { + var today = DateTime.Today; + if (filterPeriod == "daily") + { + filterStartDate = today; + filterEndDate = today; + } + else if (filterPeriod == "monthly") + { + filterStartDate = new DateTime(today.Year, today.Month, 1); + filterEndDate = new DateTime(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month)); + } + else if (filterPeriod == "yearly") + { + filterStartDate = new DateTime(today.Year, 1, 1); + filterEndDate = new DateTime(today.Year, 12, 31); + } + else + { + filterStartDate = null; + filterEndDate = null; + } + } + + private async Task LoadProjectReportData() + { + var client = ApiClient.CreateClient(); + try + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(searchQuery)) parts.Add($"search={Uri.EscapeDataString(searchQuery)}"); + if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}"); + if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}"); + if (!string.IsNullOrWhiteSpace(filterStatus) && filterStatus != "0") parts.Add($"status={Uri.EscapeDataString(filterStatus)}"); + if (filterAssignToMe) parts.Add("assignedToMe=true"); + if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true"); + + // 1. Fetch full matching list for chart view, cards summary, and exports + var fullUrl = "Report/project-progress" + (parts.Count > 0 ? "?" + string.Join("&", parts) : ""); + var fullResult = await client.GetFromJsonAsync>>(fullUrl, Serialization.CaseInsensitive); + if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null) + { + allProjects = fullResult.Value; + + totalActiveProjects = allProjects.Count; + aheadOfScheduleCount = allProjects.Count(m => m.IsAhead); + atRiskCount = allProjects.Count(m => m.IsAtRisk); + } + + // 2. Fetch paged list for table pagination + var pagedParts = new List(parts); + pagedParts.Add($"page={currentPage}"); + pagedParts.Add($"limit={pageSize}"); + var pagedUrl = "Report/project-progress" + (pagedParts.Count > 0 ? "?" + string.Join("&", pagedParts) : ""); + var pagedResult = await client.GetFromJsonAsync>(pagedUrl, Serialization.CaseInsensitive); + if (pagedResult != null) + { + reportPage = pagedResult; + pagedProjects = pagedResult.Items.ToList(); + currentPage = pagedResult.Page; + pageSize = pagedResult.PageSize; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading project progress report: {ex.Message}"); + } + } + + private async Task DownloadExcel() + { + var rows = allProjects.Select(proj => new object?[] + { + proj.ProjectName, + proj.StartDate, + proj.EndDate, + proj.OpenIssues, + proj.OverdueIssues, + proj.BlockedIssues, + proj.ProjectHealth, + proj.ActualHours + }); + + var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook( + "Project Progress", + "Project Progress Report", + new[] { "Project", "Start Date", "End Date", "Open Issues", "Overdue Issues", "Blocked Issues", "Health", "Actual Hours" }, + rows, + new[] + { + $"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All projects" : searchQuery)}", + $"Period: {filterPeriod}", + $"Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"ProjectProgressReport_{DateTime.Today:yyyyMMdd}.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Convert.ToBase64String(workbookBytes)); + } + + private async Task DownloadPdf() + { + var summaryLines = new List + { + $"Search: {(string.IsNullOrWhiteSpace(searchQuery) ? "All projects" : searchQuery)}", + $"Period: {filterPeriod}", + $"Status: {filterStatus switch { "0" => "All", "1" => "To Do", "2" => "In Progress", "3" => "Done", "overdue" => "Overdue", _ => filterStatus }}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = allProjects.Select(proj => new[] + { + proj.ProjectName, + DisplayFormats.Date(proj.StartDate), + DisplayFormats.Date(proj.EndDate), + proj.OpenIssues.ToString(), + proj.OverdueIssues.ToString(), + proj.BlockedIssues.ToString(), + proj.ProjectHealth, + proj.ActualHours.ToString("N1") + }); + + var pdfBytes = SimplePdfReportBuilder.BuildTableReport( + "Project Progress Report", + summaryLines, + new[] { "Project", "Start Date", "End Date", "Open", "Overdue", "Blocked", "Health", "Hours" }, + rows); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"ProjectProgressReport_{DateTime.Today:yyyyMMdd}.pdf", + "application/pdf", + Convert.ToBase64String(pdfBytes)); + } + + private static string GetProjectHealthBadge(string health) => health switch + { + "Critical" => "bg-rose-50 text-rose-700", + "At Risk" => "bg-amber-50 text-amber-700", + _ => "bg-emerald-50 text-emerald-700" + }; + + private static double GetProjectRiskScore(ProjectProgressReportDto project) + { + var issueRisk = (project.OpenIssues * 8) + (project.OverdueIssues * 16) + (project.BlockedIssues * 22); + var progressRisk = Math.Max(0, 65 - project.Progress) * 0.45; + var healthRisk = project.ProjectHealth == "Critical" ? 25 : project.IsAtRisk ? 15 : 0; + return Math.Min(100, issueRisk + progressRisk + healthRisk); + } + + private static int GetProjectSeverity(ProjectProgressReportDto project) + { + if (project.ProjectHealth == "Critical" || project.BlockedIssues > 0) + { + return 3; + } + + return project.IsAtRisk || project.OverdueIssues > 0 ? 2 : 1; + } + + private static int GetProjectUrgency(ProjectProgressReportDto project) + { + if (project.Progress < 35 || project.OverdueIssues > 2) + { + return 3; + } + + return project.Progress < 70 || project.OpenIssues > 0 ? 2 : 1; + } + + private sealed record ProjectQuadrantPoint(string Label, double Left, double Top, string Color, string Tooltip); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..0997e11bc663ac40656ba39928dfaedc2187a398 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor @@ -0,0 +1,740 @@ +@page "/reports/tasks" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.Report +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.Shared.Models.Project +@using System.Text +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.PageTitle("taskReport", "Task Report") + +
+ +
+
+

@AppLocalization.PageTitle("taskReport", "Task Report")

+

@AppLocalization.PageDescription("taskReport", "Analyze task activity and export task reports.")

+
+
+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + @if (filterPeriod == "custom") + { +
+ + +
+
+ + +
+ } + +
+ + +
+
+ +
+ + @if (filterProjectId > 0 || !string.IsNullOrWhiteSpace(projectSearchInput)) + { + + } +
+ @if (showProjectSuggestions && FilteredProjects.Any()) + { +
    +
  • + +
  • + @foreach (var p in FilteredProjects.Take(10)) + { +
  • + +
  • + } +
+ } +
+ + + +
+ + +
+
+
+ + @if (isLoading) + { +
+
+ + @AppLocalization.Text("common.loading", "Loading data...") +
+
+ } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { +
+
+ +
+

@AppLocalization.Text("report.couldNotLoad", "Task report could not load.")

+

@errorMessage

+
+
+
+ } + else + { +
+ @TaskMetricCard(AppLocalization.Text("report.total", "Total"), FilteredList.Count().ToString(), "file-text", "violet") + @TaskMetricCard(AppLocalization.Text("status.done", "Completed"), FilteredList.Count(t => t.StatusId == AppTaskStatus.Done).ToString(), "check-circle-2", "emerald") + @TaskMetricCard(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress).ToString(), "timer", "blue") + @TaskMetricCard(AppLocalization.Text("status.overdue", "Overdue"), FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done).ToString(), "alarm-clock", "rose") +
+ + + + @if (isChartView) + { +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ } + else + { + +
+ + + + + + + + + + + + + + @if (PagedList.Any()) + { + @foreach (var entry in PagedList.Select((t, index) => (t, index))) + { + var t = entry.t; + + + + + + + + + + @if (expandedTasks.Contains(t.TaskId)) + { + + + + } + } + } + else + { + + + + } + +
@AppLocalization.Text("common.index", "No.")@AppLocalization.Text("common.task", "Task")@AppLocalization.Text("common.project", "Project")@AppLocalization.Text("common.status", "Status")@AppLocalization.Text("common.priority", "Priority")@AppLocalization.Text("report.health", "Health")@AppLocalization.Text("common.issue", "Issues")
@((currentPage - 1) * pageSize + entry.index + 1) + + @t.ProjectName + @AppLocalization.StatusLabel(t.StatusId) + + @AppLocalization.PriorityLabel(t.PriorityId) + + @GetHealthLabel(t.TaskHealth) + @t.IssueSummary
+
+
@AppLocalization.Text("common.assignee", "Assignee"): @(t.AssignedToUser ?? AppLocalization.Text("common.unassigned", "Unassigned"))
+
@AppLocalization.Text("common.due", "Due"): @DisplayFormats.Date(t.DueDate)
+
@AppLocalization.Text("report.openIssues", "Open issues"): @t.OpenIssues
+
@AppLocalization.Text("report.lastActivity", "Last activity"): @DisplayFormats.Date(t.LastActivity)
+
+
+
+ + + + + + + + +

@AppLocalization.Text("report.noTasksFound", "No Tasks Found")

+

@AppLocalization.Text("report.noTasksFoundDesc", "There are no tasks matching your filters. Try adjusting your search query or filters.")

+
+
+ + + +
+ } + } +
+ +@code { + private bool isLoading = true; + private bool isChartView = false; + private List taskReports = new(); + private HashSet expandedTasks = new(); + private PagedResult? taskReportPage; + private List projects = new(); + private string? errorMessage; + + // Filters + private string filterPeriod = "all"; + private DateTime? filterStartDate; + private DateTime? filterEndDate; + private string? filterStatus = "uncompleted"; + private int filterProjectId; + private string projectSearchInput = string.Empty; + private string projectSearchQuery = string.Empty; + private bool showProjectSuggestions; + private bool filterAssignToMe = false; + private bool filterAssignToMyTeam = false; + + // Search and Pagination + private string searchInput = ""; + private string searchQuery = ""; + private int currentPage = 1; + private int pageSize = 10; + + private async Task ApplyAllFilters() + { + ApplyPeriodPreset(); + searchQuery = searchInput; + projectSearchQuery = projectSearchInput; + var matched = projects.FirstOrDefault(p => p.Name.Equals(projectSearchInput, StringComparison.OrdinalIgnoreCase)); + if (matched != null) + filterProjectId = (int)matched.Id; + else if (string.IsNullOrWhiteSpace(projectSearchInput)) + filterProjectId = 0; + currentPage = 1; + await LoadTaskReport(); + } + + private async Task ResetAllFilters() + { + searchInput = ""; + searchQuery = ""; + filterPeriod = "all"; + filterStartDate = null; + filterEndDate = null; + filterStatus = "uncompleted"; + filterProjectId = 0; + projectSearchInput = string.Empty; + projectSearchQuery = string.Empty; + showProjectSuggestions = false; + filterAssignToMe = false; + filterAssignToMyTeam = false; + currentPage = 1; + await LoadTaskReport(); + } + + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadTaskReport(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadTaskReport(); + } + + private IEnumerable FilteredList => taskReports; + + private int TotalPages => taskReportPage?.TotalPages ?? 0; + + private IEnumerable PagedList => taskReportPage?.Items ?? Enumerable.Empty(); + + private IEnumerable TaskInsights => new[] + { + $"{FilteredList.Count(t => t.TaskHealth == "Critical")} {AppLocalization.Text("report.criticalTasksNeedAttention", "critical tasks need attention.")}", + $"{FilteredList.Sum(t => t.OpenIssues)} {AppLocalization.Text("report.openIssuesAttached", "open issues are attached to reported tasks.")}", + $"{FilteredList.Count(t => t.OverdueIssues > 0)} {AppLocalization.Text("report.tasksHaveOverdueIssues", "tasks have overdue issues.")}" + }; + + private int FilteredTaskCount => FilteredList.Count(); + private int CompletedTaskCount => FilteredList.Count(t => t.StatusId == AppTaskStatus.Done); + private int OpenTaskCount => FilteredTaskCount - CompletedTaskCount; + private int HighPriorityCount => FilteredList.Count(t => t.PriorityId == TaskPriority.High); + private double CompletionRate => FilteredTaskCount > 0 ? CompletedTaskCount / (double)FilteredTaskCount * 100 : 0; + + private string TaskStatusInsight => OpenTaskCount > CompletedTaskCount + ? $"{OpenTaskCount} {AppLocalization.Text("report.tasksStillMoving", "tasks are still moving through execution.")}" + : AppLocalization.Text("report.completedWorkLeading", "Completed work is leading the current filtered set."); + + private string PriorityInsight => HighPriorityCount > 0 + ? $"{HighPriorityCount} {AppLocalization.Text("report.highPriorityVisible", "high-priority tasks should stay visible during planning review.")}" + : AppLocalization.Text("report.noHighPriority", "No high-priority tasks in the current filter."); + + private string CompletionInsight => CompletionRate >= 70 + ? AppLocalization.Text("report.completionHealthy", "Completion is healthy for the current filter.") + : AppLocalization.Text("report.completionImprove", "Completion has room to improve; inspect open issues and overdue work."); + + private List TaskStatusItems => new() + { + new(AppLocalization.StatusLabel(AppTaskStatus.Todo), FilteredList.Count(t => t.StatusId == AppTaskStatus.Todo), "#94a3b8"), + new(AppLocalization.StatusLabel(AppTaskStatus.InProgress), FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress), "#3b82f6"), + new(AppLocalization.StatusLabel(AppTaskStatus.Done), CompletedTaskCount, "#10b981"), + new(AppLocalization.Text("status.overdue", "Overdue"), FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done), "#ef4444") + }; + + private List PrioritySegments => new() + { + new(AppLocalization.PriorityLabel(TaskPriority.High), HighPriorityCount, "#ef4444"), + new(AppLocalization.PriorityLabel(TaskPriority.Medium), FilteredList.Count(t => t.PriorityId == TaskPriority.Medium), "#f59e0b"), + new(AppLocalization.PriorityLabel(TaskPriority.Low), FilteredList.Count(t => t.PriorityId == TaskPriority.Low), "#3b82f6") + }; + + private List CompletionSparkline => FilteredList + .GroupBy(t => new DateTime(t.CreatedAt.Year, t.CreatedAt.Month, 1)) + .OrderBy(g => g.Key) + .TakeLast(8) + .Select(g => new SparklinePoint(g.Key.ToString("MMM"), g.Count(t => t.StatusId == AppTaskStatus.Done))) + .DefaultIfEmpty(new SparklinePoint(DateTime.Today.ToString("MMM"), CompletedTaskCount)) + .ToList(); + + private List ProjectRiskItems => FilteredList + .GroupBy(t => t.ProjectName) + .Select(g => + { + var score = Math.Min(100, (g.Count() * 8) + (g.Sum(t => t.OpenIssues) * 12) + (g.Sum(t => t.OverdueIssues) * 18) + (g.Count(t => t.TaskHealth == "Critical") * 20)); + var overdue = g.Sum(t => t.OverdueIssues); + var tone = overdue > 0 ? "rose" : score >= 45 ? "amber" : "blue"; + + return new TopRiskItem( + g.Key, + $"{g.Count()} {AppLocalization.Text("common.task", "tasks")} · {g.Sum(t => t.OpenIssues)} {AppLocalization.Text("common.issue", "open issues")}", + score, + overdue > 0 ? $"{overdue} {AppLocalization.Text("status.overdue", "overdue")}" : $"{score:0} {AppLocalization.Text("report.risk", "risk")}", + tone, + $"{g.Count(t => t.PriorityId == TaskPriority.High)} {AppLocalization.Text("report.highPriorityTasks", "high-priority tasks")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List TaskIssueRiskItems => FilteredList + .OrderByDescending(t => (t.OpenIssues * 2) + (t.OverdueIssues * 3)) + .Take(5) + .Select(t => + { + var score = Math.Min(100, (t.OpenIssues * 20) + (t.OverdueIssues * 30)); + var tone = t.OverdueIssues > 0 ? "rose" : t.OpenIssues > 0 ? "amber" : "emerald"; + + return new TopRiskItem( + t.Title, + t.ProjectName, + score, + $"{t.OpenIssues + t.OverdueIssues} {AppLocalization.Text("common.issue", "issues")}", + tone, + $"{AppLocalization.StatusLabel(t.StatusId)} · {AppLocalization.PriorityLabel(t.PriorityId)}"); + }) + .ToList(); + + private List TasksByProjectItems => FilteredList + .GroupBy(t => t.ProjectName) + .OrderByDescending(g => g.Count()) + .Take(5) + .Select(g => new ProgressListChartItem(g.Key, g.Count(), g.Count().ToString(), "#7c3aed")) + .ToList(); + + private double TasksByProjectMax => Math.Max(1, TasksByProjectItems.Select(item => item.Value).DefaultIfEmpty(0).Max()); + + private List TasksWithMostIssuesItems => FilteredList + .OrderByDescending(t => t.OpenIssues + t.OverdueIssues) + .Take(5) + .Select(t => new ProgressListChartItem(t.Title, t.OpenIssues + t.OverdueIssues, (t.OpenIssues + t.OverdueIssues).ToString(), "#7c3aed")) + .ToList(); + + private double TasksWithMostIssuesMax => Math.Max(1, TasksWithMostIssuesItems.Select(item => item.Value).DefaultIfEmpty(0).Max()); + + protected override async Task OnInitializedAsync() + { + var client = ApiClient.CreateClient(); + try + { + projects = await client.GetFromJsonAsync>("Project") ?? new(); + } + catch { } + + await LoadTaskReport(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private IEnumerable FilteredProjects => + string.IsNullOrWhiteSpace(projectSearchQuery) + ? projects + : projects.Where(p => p.Name.Contains(projectSearchQuery.Trim(), StringComparison.OrdinalIgnoreCase)); + + private void SetView(bool chartView) + { + isChartView = chartView; + } + + private void ToggleExpanded(long taskId) + { + if (!expandedTasks.Remove(taskId)) + { + expandedTasks.Add(taskId); + } + } + + private void ShowProjectSuggestions() + { + projectSearchQuery = projectSearchInput; + showProjectSuggestions = true; + } + + private async Task HideProjectSuggestionsDelayed() + { + await Task.Delay(150); + showProjectSuggestions = false; + await InvokeAsync(StateHasChanged); + } + + private void SelectProject(ProjectDto project) + { + filterProjectId = (int)project.Id; + projectSearchInput = project.Name; + projectSearchQuery = project.Name; + showProjectSuggestions = false; + } + + private void ClearProjectFilter() + { + filterProjectId = 0; + projectSearchInput = string.Empty; + projectSearchQuery = string.Empty; + showProjectSuggestions = false; + } + + private void ApplyPeriodPreset() + { + var today = DateTime.Today; + if (filterPeriod == "daily") + { + filterStartDate = today; + filterEndDate = today; + } + else if (filterPeriod == "monthly") + { + filterStartDate = new DateTime(today.Year, today.Month, 1); + filterEndDate = new DateTime(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month)); + } + else if (filterPeriod == "yearly") + { + filterStartDate = new DateTime(today.Year, 1, 1); + filterEndDate = new DateTime(today.Year, 12, 31); + } + else + { + filterStartDate = null; + filterEndDate = null; + } + } + + private async Task LoadTaskReport() + { + isLoading = true; + projectSearchQuery = projectSearchInput; + StateHasChanged(); + + var client = ApiClient.CreateClient(); + try + { + errorMessage = null; + var query = BuildTaskReportQuery(); + var fullTask = client.GetFromJsonAsync>>(query); + var pagedTask = client.GetFromJsonAsync>(BuildTaskReportQuery(includePaging: true)); + await System.Threading.Tasks.Task.WhenAll(fullTask, pagedTask); + + if (fullTask.Result?.IsSuccess == true && fullTask.Result.Value != null) + { + taskReports = fullTask.Result.Value; + } + else if (fullTask.Result?.IsSuccess == false) + { + errorMessage = fullTask.Result.ErrorMessage ?? AppLocalization.Text("report.loadError", "The server returned an error while loading task report data."); + } + + if (pagedTask.Result != null) + { + taskReportPage = pagedTask.Result; + currentPage = taskReportPage.Page; + pageSize = taskReportPage.PageSize; + } + } + catch (Exception ex) + { + Console.WriteLine($"Task report error: {ex.Message}"); + errorMessage = ex.Message; + taskReports = new(); + taskReportPage = null; + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private string BuildTaskReportQuery(bool includePaging = false) + { + var parts = new List(); + if (filterStartDate.HasValue) parts.Add($"startDate={filterStartDate:yyyy-MM-dd}"); + if (filterEndDate.HasValue) parts.Add($"endDate={filterEndDate:yyyy-MM-dd}"); + if (!string.IsNullOrEmpty(filterStatus)) parts.Add($"status={Uri.EscapeDataString(filterStatus)}"); + if (filterProjectId > 0) parts.Add($"projectId={filterProjectId}"); + if (filterAssignToMe) parts.Add("assignedToMe=true"); + if (filterAssignToMyTeam) parts.Add("assignedToMyTeam=true"); + if (includePaging) + { + parts.Add($"page={currentPage}"); + parts.Add($"limit={pageSize}"); + } + return "Report/tasks" + (parts.Count > 0 ? "?" + string.Join("&", parts) : ""); + } + + private async Task DownloadExcel() + { + var rows = FilteredList.Select(t => new object?[] + { + t.Title, + t.ProjectName, + AppLocalization.StatusLabel(t.StatusId), + AppLocalization.PriorityLabel(t.PriorityId), + t.AssignedToUser ?? AppLocalization.Text("common.na", "Unassigned"), + t.DueDate + }); + + var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook( + AppLocalization.PageTitle("taskReport", "Task Report"), + AppLocalization.PageTitle("taskReport", "Task Report"), + new[] { AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("common.assignee", "Assigned To"), AppLocalization.Text("common.dueDate", "Due Date") }, + rows, + new[] + { + $"{AppLocalization.Text("common.search", "Search")}: {(string.IsNullOrWhiteSpace(searchQuery) ? AppLocalization.Text("common.all", "All tasks") : searchQuery)}", + $"{AppLocalization.Text("report.periodFilter", "Period")}: {GetPeriodLabel(filterPeriod)}", + $"{AppLocalization.Text("common.status", "Status")}: {filterStatus switch { "0" => AppLocalization.Text("common.all", "All"), "1" => AppLocalization.StatusLabel(AppTaskStatus.Todo), "2" => AppLocalization.StatusLabel(AppTaskStatus.InProgress), "3" => AppLocalization.StatusLabel(AppTaskStatus.Done), "overdue" => AppLocalization.Text("status.overdue", "Overdue"), "uncompleted" => AppLocalization.Text("status.uncompleted", "Uncompleted"), _ => filterStatus }}", + $"{AppLocalization.Text("common.project", "Project")}: {(filterProjectId > 0 ? projects.FirstOrDefault(p => p.Id == filterProjectId)?.Name ?? AppLocalization.Text("report.selectedProject", "Selected project") : AppLocalization.Text("common.all", "All projects"))}", + $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" + }); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"TaskReport_{DateTime.Today:yyyyMMdd}.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Convert.ToBase64String(workbookBytes)); + } + + private async Task DownloadPdf() + { + var summaryLines = new List + { + $"{AppLocalization.Text("common.search", "Search")}: {(string.IsNullOrWhiteSpace(searchQuery) ? AppLocalization.Text("common.all", "All tasks") : searchQuery)}", + $"{AppLocalization.Text("report.periodFilter", "Period")}: {GetPeriodLabel(filterPeriod)}", + $"{AppLocalization.Text("common.status", "Status")}: {filterStatus switch { "0" => AppLocalization.Text("common.all", "All"), "1" => AppLocalization.StatusLabel(AppTaskStatus.Todo), "2" => AppLocalization.StatusLabel(AppTaskStatus.InProgress), "3" => AppLocalization.StatusLabel(AppTaskStatus.Done), "overdue" => AppLocalization.Text("status.overdue", "Overdue"), "uncompleted" => AppLocalization.Text("status.uncompleted", "Uncompleted"), _ => filterStatus }}", + $"{AppLocalization.Text("common.project", "Project")}: {(filterProjectId > 0 ? projects.FirstOrDefault(p => p.Id == filterProjectId)?.Name ?? AppLocalization.Text("report.selectedProject", "Selected project") : AppLocalization.Text("common.all", "All projects"))}", + $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = FilteredList.Select(t => new[] + { + t.Title, + t.ProjectName, + AppLocalization.StatusLabel(t.StatusId), + AppLocalization.PriorityLabel(t.PriorityId), + t.AssignedToUser ?? AppLocalization.Text("common.na", "Unassigned"), + DisplayFormats.Date(t.DueDate) + }); + + var pdfBytes = SimplePdfReportBuilder.BuildTableReport( + AppLocalization.PageTitle("taskReport", "Task Report"), + summaryLines, + new[] { AppLocalization.Text("common.task", "Task"), AppLocalization.Text("common.project", "Project"), AppLocalization.Text("common.status", "Status"), AppLocalization.Text("common.priority", "Priority"), AppLocalization.Text("common.assignee", "Assigned To"), AppLocalization.Text("common.dueDate", "Due Date") }, + rows); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"TaskReport_{DateTime.Today:yyyyMMdd}.pdf", + "application/pdf", + Convert.ToBase64String(pdfBytes)); + } + + private string GetStatusBadge(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "bg-slate-100 text-slate-700", + AppTaskStatus.InProgress => "bg-blue-100 text-blue-700", + AppTaskStatus.Done => "bg-emerald-100 text-emerald-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-100 text-amber-700", + TaskPriority.High => "bg-red-100 text-red-700", + _ => "bg-slate-100 text-slate-600" + }; + + private static string GetHealthLabel(string health) => health switch + { + "Critical" => AppLocalization.Text("report.healthCritical", "Critical"), + "At Risk" => AppLocalization.Text("report.healthAtRisk", "At Risk"), + _ => AppLocalization.Text("report.healthGood", "Healthy") + }; + + private static string GetHealthBadge(string health) => health switch + { + "Critical" => "bg-rose-50 text-rose-700", + "At Risk" => "bg-amber-50 text-amber-700", + _ => "bg-emerald-50 text-emerald-700" + }; + + private static string GetPeriodLabel(string period) => period switch + { + "daily" => AppLocalization.Text("report.daily", "Daily (Today)"), + "monthly" => AppLocalization.Text("report.monthly", "Monthly (This Month)"), + "yearly" => AppLocalization.Text("report.yearly", "Yearly (This Year)"), + "custom" => AppLocalization.Text("report.customRange", "Custom Range"), + _ => AppLocalization.Text("common.all", "All Time") + }; + + private RenderFragment TaskMetricCard(string label, string value, string icon, string color) => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", $"rounded-xl border border-{color}-100 bg-{color}-50 p-5 shadow-sm"); + builder.AddMarkupContent(2, $"

{label}

{value}

"); + builder.CloseElement(); + }; + +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor new file mode 100644 index 0000000000000000000000000000000000000000..0796254249eb36e3f5e7c032b1fa859fd54546ed --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor @@ -0,0 +1,659 @@ +@page "/reports/timesheet" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.Issue +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.Shared.Models.Project +@using System.Text +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.Text("report.timeTrackingTitle", "Time & Effort Tracking Report") + +
+ +
+
+

@AppLocalization.Text("report.timeTrackingHeading", "Time & Effort Tracking")

+

@AppLocalization.Text("report.timeTrackingDesc", "Review estimated hours, completion timelines, and resource effort distribution across issues.")

+
+
+
+ + +
+ + +
+
+ + + @if (!isLoading) + { +
+
+
+
+

Total Estimated Effort

+

@totalHours.ToString("N1") hrs

+
+ + + +
+
+
+
+
+

Actual Hours

+

@actualHours.ToString("N1") hrs

+
+ + + +
+
+
+
+
+

Variance

+

@varianceHours.ToString("+0.0;-0.0;0.0") hrs

+
+ + + +
+
+
+
+
+

Utilization

+

@utilizationPercent.ToString("N0")%

+
+ + + +
+
+
+ } + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+
+
+ + @if (isLoading) + { + + } + else + { + @if (isChartView) + { +
+
+ +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ } + else + { + +
+
+

+ Showing @FilteredIssues.Count() timesheet issue entry(s) +

+
+ + + + + + + + + + + + + + + @if (PagedIssues.Any()) + { + @foreach (var entry in PagedIssues.Select((issue, index) => (issue, index))) + { + var issue = entry.issue; + + + + + + + + + + + } + } + else + { + + } + +
No.IssueTask & ProjectAssigneeEst. HoursActual HoursDue DateStatus
@((currentPage - 1) * pageSize + entry.index + 1) +
+

@issue.Title

+ @if (!string.IsNullOrEmpty(issue.Description)) + { +

@issue.Description

+ } +
+
+
@issue.TaskTitle
+
@issue.ProjectName
+
+ @if (!string.IsNullOrEmpty(issue.AssignedToName)) + { +
+ + @(issue.AssignedToName.Length > 0 ? issue.AssignedToName[0].ToString().ToUpper() : "") + + @issue.AssignedToName +
+ } + else + { + Unassigned + } +
+ @((issue.EstimatedHours ?? 0).ToString("N1")) hrs + + @((issue.ActualHours ?? 0).ToString("N1")) hrs + + @DisplayFormats.Date(issue.DueDate) + + @{ + var (statusLabel, statusClass) = issue.StatusId switch { + AppTaskStatus.InProgress => ("In Progress", "bg-blue-50 text-blue-700"), + AppTaskStatus.Done => ("Completed", "bg-emerald-50 text-emerald-700"), + _ => ("To Do", "bg-slate-100 text-slate-600") + }; + } + @statusLabel +
+ + + +
+ } + } +
+ +@code { + private bool isLoading = true; + private bool isChartView = false; + private List allIssues = new(); + private List allProjects = new(); + + // Filters + private string searchInput = ""; + private string appliedSearch = ""; + private long filterProjectId = 0; + private long appliedProjectId = 0; + private string filterStatus = "0"; + private string appliedStatus = "0"; + private bool filterAssignToMe = false; + private bool filterAssignToMyTeam = false; + + // Current User & Team + private long currentUserId = 0; + private List myTeamUserIds = new(); + + // Pagination + private int currentPage = 1; + private int pageSize = 10; + + // Metrics + private decimal totalHours = 0; + private decimal actualHours = 0; + private decimal varianceHours = 0; + private double utilizationPercent = 0; + + private int TotalPages => (int)Math.Ceiling((double)FilteredIssues.Count() / pageSize); + + private IEnumerable FilteredIssues + { + get + { + var result = allIssues.AsEnumerable(); + + if (appliedProjectId > 0) + result = result.Where(i => i.ProjectId == appliedProjectId); + + if (appliedStatus != "0" && Enum.TryParse(appliedStatus, out var status)) + result = result.Where(i => i.StatusId == status); + + if (!string.IsNullOrWhiteSpace(appliedSearch)) + { + var q = appliedSearch.Trim().ToLower(); + result = result.Where(i => + i.Title.ToLower().Contains(q) || + (i.Description != null && i.Description.ToLower().Contains(q)) || + (i.AssignedToName != null && i.AssignedToName.ToLower().Contains(q)) || + i.TaskTitle.ToLower().Contains(q) + ); + } + + if (filterAssignToMe || filterAssignToMyTeam) + { + result = result.Where(i => + (filterAssignToMe && i.AssignedTo == currentUserId) || + (filterAssignToMyTeam && i.AssignedTo.HasValue && myTeamUserIds.Contains(i.AssignedTo.Value)) + ); + } + + return result.OrderBy(i => i.Title); + } + } + + private IEnumerable PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize); + + protected override async Task OnInitializedAsync() + { + var client = ApiClient.CreateClient(); + try + { + var issuesTask = client.GetFromJsonAsync>>("Report/issues", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + + // Get current user ID + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + if (user.Identity?.IsAuthenticated == true) + { + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (long.TryParse(idStr, out var parsedId)) + { + currentUserId = parsedId; + } + } + + // Get team IDs + try + { + var teamTask = client.GetFromJsonAsync>("User/my-team-user-ids", Serialization.CaseInsensitive); + await Task.WhenAll(issuesTask, projectsTask, teamTask); + myTeamUserIds = teamTask.Result ?? new(); + } + catch + { + await Task.WhenAll(issuesTask, projectsTask); + } + + var issuesResult = issuesTask.Result; + if (issuesResult?.IsSuccess == true && issuesResult.Value != null) + { + allIssues = issuesResult.Value; + } + allProjects = projectsTask.Result ?? new(); + + ComputeMetrics(); + } + catch (Exception ex) + { + Console.WriteLine($"Error fetching timesheet data: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private void SetView(bool chartView) + { + isChartView = chartView; + } + + private void ComputeMetrics() + { + var today = DateTime.Today; + var list = FilteredIssues.ToList(); + totalHours = list.Sum(i => i.EstimatedHours ?? 0); + actualHours = list.Sum(i => i.ActualHours ?? 0); + varianceHours = actualHours - totalHours; + utilizationPercent = totalHours > 0 ? Math.Round((double)(actualHours / totalHours) * 100, 1) : 0; + } + + private static decimal GetVariance(IssueDto issue) => (issue.ActualHours ?? 0) - (issue.EstimatedHours ?? 0); + + private int OnOrUnderEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)); + private int OverEstimateCount => FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)); + private string UtilizationColor => utilizationPercent > 115 ? "#ef4444" : utilizationPercent > 95 ? "#f59e0b" : "#10b981"; + private string EffortVarianceInsight => varianceHours > 0 + ? $"Actual effort is {varianceHours:N1}h above estimate." + : $"Actual effort is {Math.Abs(varianceHours):N1}h under estimate."; + private string UtilizationInsight => utilizationPercent > 115 + ? "Utilization is above plan; review estimates and blockers." + : utilizationPercent < 70 ? "Tracked actual effort is below plan; confirm work is being logged." : "Utilization is close to the planning range."; + private string EstimateAccuracyInsight => OverEstimateCount > OnOrUnderEstimateCount + ? "More issues are exceeding estimate than staying within it." + : "Most issues are within or under their estimates."; + + private List EffortComparisonSegments => new() + { + new("Actual", (double)Math.Max(0, actualHours), "#3b82f6"), + new("Remaining estimate", (double)Math.Max(0, totalHours - actualHours), "#dbeafe") + }; + + private List EstimateAccuracyDonutItems => new() + { + new("On / Under", OnOrUnderEstimateCount, "#10b981"), + new("Over", OverEstimateCount, "#ef4444") + }; + + private List ProjectEffortSegments => FilteredIssues + .GroupBy(i => i.ProjectName) + .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) + .Take(6) + .Select((g, index) => new StackedSegment(g.Key, (double)g.Sum(i => i.ActualHours ?? 0), ChartPalette[index % ChartPalette.Length])) + .ToList(); + + private List EmployeeEffortLeaders => FilteredIssues + .GroupBy(i => i.AssignedToName ?? "Unassigned") + .Select(g => + { + var actual = g.Sum(i => i.ActualHours ?? 0); + var estimated = g.Sum(i => i.EstimatedHours ?? 0); + var score = estimated > 0 ? Math.Min(100, (double)(actual / estimated) * 100) : Math.Min(100, (double)actual * 10); + var tone = score > 115 ? "rose" : score > 90 ? "amber" : "blue"; + + return new TopRiskItem( + g.Key, + $"{actual:N1}h actual · {estimated:N1}h estimated", + score, + $"{g.Count()} issues", + tone, + $"{g.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))} over estimate"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private static readonly string[] ChartPalette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"]; + + private List EstimatedVsActualItems => new[] + { + new ProgressListChartItem("Estimated", (double)totalHours, $"{totalHours:N1}", "#7c3aed"), + new ProgressListChartItem("Actual", (double)actualHours, $"{actualHours:N1}", "#06b6d4") + }.ToList(); + + private double EstimatedVsActualMax => GetMax(EstimatedVsActualItems); + + private List EffortByProjectItems => FilteredIssues + .GroupBy(i => i.ProjectName) + .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) + .Take(5) + .Select(g => + { + var hours = (double)g.Sum(i => i.ActualHours ?? 0); + return new ProgressListChartItem(g.Key, hours, $"{hours:N1}", "#7c3aed"); + }) + .ToList(); + + private double EffortByProjectMax => GetMax(EffortByProjectItems); + + private List EffortByEmployeeItems => FilteredIssues + .GroupBy(i => i.AssignedToName ?? "Unassigned") + .OrderByDescending(g => g.Sum(i => i.ActualHours ?? 0)) + .Take(5) + .Select(g => + { + var hours = (double)g.Sum(i => i.ActualHours ?? 0); + return new ProgressListChartItem(g.Key, hours, $"{hours:N1}", "#7c3aed"); + }) + .ToList(); + + private double EffortByEmployeeMax => GetMax(EffortByEmployeeItems); + + private List EstimateAccuracyItems => new[] + { + new ProgressListChartItem("On / Under", FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)), FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)).ToString(), "#10b981"), + new ProgressListChartItem("Over", FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)), FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)).ToString(), "#ef4444") + }.ToList(); + + private double EstimateAccuracyMax => GetMax(EstimateAccuracyItems); + + private static double GetMax(IEnumerable items) + { + return Math.Max(1, items.Select(item => item.Value).DefaultIfEmpty(0).Max()); + } + + private Task ApplyFilters() + { + appliedSearch = searchInput; + appliedProjectId = filterProjectId; + appliedStatus = filterStatus; + currentPage = 1; + ComputeMetrics(); + return Task.CompletedTask; + } + + private Task ResetFilters() + { + searchInput = ""; + appliedSearch = ""; + filterProjectId = 0; + appliedProjectId = 0; + filterStatus = "0"; + appliedStatus = "0"; + filterAssignToMe = false; + filterAssignToMyTeam = false; + currentPage = 1; + ComputeMetrics(); + return Task.CompletedTask; + } + + private Task HandlePageChanged(int page) + { + currentPage = page; + return Task.CompletedTask; + } + + private Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + return Task.CompletedTask; + } + + private async Task DownloadExcel() + { + var rows = FilteredIssues.Select(issue => + { + var statusLabel = issue.StatusId switch { + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Completed", + _ => "To Do" + }; + + return new object?[] + { + issue.Title, + issue.TaskTitle, + issue.ProjectName, + issue.AssignedToName ?? "Unassigned", + issue.EstimatedHours ?? 0, + issue.DueDate, + statusLabel + }; + }); + + var workbookBytes = SpreadsheetReportBuilder.BuildTableWorkbook( + "Time Effort Tracking", + "Timesheet Effort Report", + new[] { "Issue", "Task", "Project", "Assignee", "Estimated Effort (Hours)", "Due Date", "Status" }, + rows, + new[] + { + $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All" : appliedSearch)}", + $"Project: {(appliedProjectId > 0 ? allProjects.FirstOrDefault(p => p.Id == appliedProjectId)?.Name ?? "All" : "All")}", + $"Status: {(appliedStatus != "0" ? appliedStatus : "All")}", + $"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}", + $"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"TimesheetEffortReport_{DateTime.Today:yyyyMMdd}.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Convert.ToBase64String(workbookBytes)); + } + + private async Task DownloadPdf() + { + var summaryLines = new List + { + $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All" : appliedSearch)}", + $"Project: {(appliedProjectId > 0 ? allProjects.FirstOrDefault(p => p.Id == appliedProjectId)?.Name ?? "All" : "All")}", + $"Status: {(appliedStatus != "0" ? appliedStatus : "All")}", + $"Assigned To Me: {(filterAssignToMe ? "Yes" : "No")}", + $"Assigned To My Team: {(filterAssignToMyTeam ? "Yes" : "No")}", + $"Total Effort: {totalHours.ToString("N1")} hrs", + $"Actual Hours: {actualHours.ToString("N1")} hrs", + $"Generated: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = FilteredIssues.Select(issue => + { + var statusLabel = issue.StatusId switch { + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Completed", + _ => "To Do" + }; + + return new[] + { + issue.Title, + issue.TaskTitle, + issue.ProjectName, + issue.AssignedToName ?? "Unassigned", + (issue.EstimatedHours ?? 0).ToString("N1") + " hrs", + DisplayFormats.Date(issue.DueDate), + statusLabel + }; + }); + + var pdfBytes = SimplePdfReportBuilder.BuildTableReport( + "Timesheet Effort Tracking Report", + summaryLines, + new[] { "Issue", "Task", "Project", "Assignee", "Est. Effort", "Due Date", "Status" }, + rows); + + await JS.InvokeVoidAsync( + "downloadFileFromBase64", + $"TimesheetEffortReport_{DateTime.Today:yyyyMMdd}.pdf", + "application/pdf", + Convert.ToBase64String(pdfBytes)); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Roles/Roles.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Roles/Roles.razor new file mode 100644 index 0000000000000000000000000000000000000000..8037cef1899fd602ab613641d8645685a7d4537e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Roles/Roles.razor @@ -0,0 +1,1368 @@ +@page "/roles" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Models.Menu +@using TaskTrackingSystem.Shared.Models.User +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + + + +Roles & Access + + + +
+ +
+
+

Roles & Access

+

Manage roles, assign access and permissions, and control visibility.

+
+ @if (canCreateRole) + { + + } +
+ + @if (isLoading) + { + + } + else + { + +
+
+
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + @if (PagedRoles.Any()) + { + @foreach (var entry in PagedRoles.Select((role, index) => (role, index))) + { + var role = entry.role; + + + + + + + + } + } + else + { + + } + +
No.RoleDescriptionCreatedActions
@((currentPage - 1) * pageSize + entry.index + 1) +
+ + + + @role.Name +
+
@(role.Description ?? "-")@DisplayFormats.Date(role.CreatedAt) +
+ @if (canManageAccess) + { + + } + @if (canEditRole) + { + + } + @if (canDeleteRole) + { + + } +
+
+ + + +
+ } +
+ +@* ===== Create / Edit Role Modal ===== *@ +@if (showRoleModal) +{ +
+
+ + +
+
+ + + +

+ @(isEditing ? "Edit Role" : "Create New Role") +

+
+ +
+ + +
+ + +
+
+ +
+ + @if (isRoleNameError) + { + + } +
+ @if (isRoleNameError) + { +

@roleNameErrorMsg

+ } +
+
+ + +
+ @if (!string.IsNullOrEmpty(roleError)) + { +
+ +

@roleError

+
+ } + @if (!isEditing) + { +

+ + Choose what this role can see and do on the right. It will be saved with the role. +

+ } +
+ + + @if (!isEditing) + { +
+
+
+ + Assign Access + +
+
+
+ + +
+ @if (formAccessItems.Any()) + { + + } +
+
+ +
+ @if (isLoadingFormAccessItems) + { +
+ + + + +
+ } + else if (!formAccessItems.Any()) + { +

No access items found in database.

+ } + else + { +
+ Page access lets the role open this section. Permissions let it use the buttons and actions inside. + @formSelectedAccessCodes.Count selected +
+ + @if (GetRootAccessItems(true).Any()) + { +
+ @foreach (var parent in GetRootAccessItems(true)) + { + var children = GetChildAccessItems(parent, true).ToList(); + var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode); + +
+ + +
+ @if (parent.Permissions.Any()) + { +
+
+ Actions + CRUD permissions for this page +
+
+ @foreach (var action in parent.Permissions) + { + var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode); + + } +
+
+ } + + @if (children.Any()) + { +
+ @foreach (var child in children) + { + var childChecked = formSelectedAccessCodes.Contains(child.MenuCode); +
+ + + @if (child.Permissions.Any()) + { +
+ @foreach (var action in child.Permissions) + { + var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode); + + } +
+ } +
+ } +
+ } + else if (!parent.Permissions.Any()) + { +

Nothing else is available inside this page.

+ } +
+
+ } +
+ } + else + { +

No pages or permissions match your search.

+ } + } +
+
+ } +
+ + +
+ + @if (!isEditing && formSelectedAccessCodes.Count > 0) + { + @formSelectedAccessCodes.Count selected item(s) + will be saved + } + +
+ + +
+
+
+
+} + +@* ===== Assign Access Modal ===== *@ +@* ===== Role Access Modal ===== *@ +@if (showAccessModal) +{ +
+
+ + +
+
+
+ +
+
+

Role Access - @selectedRoleName

+

Configure access and permissions

+
+
+ +
+ + +
+
+ + +
+
+
+ + +
+ @selectedAccessCodes.Count selected +
+
+ + +
+ @if (isLoadingAccessItems) + { +
+ + + + +
+ } + else if (!accessItems.Any()) + { +
+ +

No access items found in database.

+
+ } + else + { +
+ Choosing an action also turns on its page. Turning off a page clears the actions inside it. + @selectedAccessCodes.Count selected +
+ + @if (GetRootAccessItems(false).Any()) + { +
+ @foreach (var parent in GetRootAccessItems(false)) + { + var children = GetChildAccessItems(parent, false).ToList(); + var parentChecked = selectedAccessCodes.Contains(parent.MenuCode); + +
+ + +
+ @if (parent.Permissions.Any()) + { +
+
+ Actions + CRUD permissions for this page +
+
+ @foreach (var action in parent.Permissions) + { + var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode); + + } +
+
+ } + + @if (children.Any()) + { +
+ @foreach (var child in children) + { + var childChecked = selectedAccessCodes.Contains(child.MenuCode); +
+ + + @if (child.Permissions.Any()) + { +
+ @foreach (var action in child.Permissions) + { + var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode); + + } +
+ } +
+ } +
+ } + else if (!parent.Permissions.Any()) + { +

Nothing else is available inside this page.

+ } +
+
+ } +
+ } + else + { +

No pages or permissions match your search.

+ } + } +
+ + + @if (!string.IsNullOrEmpty(accessSaveError)) + { +
+
+

@accessSaveError

+
+
+ } + @if (!string.IsNullOrEmpty(accessSaveSuccess)) + { +
+
+

@accessSaveSuccess

+
+
+ } + +
+ + + Changes apply immediately after saving. + +
+ + +
+
+
+
+} + +@* ===== Confirm Dialog ===== *@ + + +@code { + private bool isLoading = true; + private bool showRoleModal = false; + private bool showAccessModal = false; + private bool isEditing = false; + private bool isSaving = false; + private bool canCreateRole = false; + private bool canEditRole = false; + private bool canDeleteRole = false; + private bool canManageAccess = false; + private string? roleError; + private bool isRoleNameError = false; + private string roleNameErrorMsg = ""; + + // Confirm dialog state + private bool showConfirmDialog = false; + private bool confirmHideCancel = false; + private string? successMessage; + private string confirmTitle = ""; + private string confirmMessage = ""; + private string confirmButtonText = ""; + private string confirmIcon = ""; + private string confirmIconBg = ""; + private string confirmIconText = ""; + private string confirmButtonClass = ""; + private Func? confirmAction; + private List roleList = new(); + private List userList = new(); + + // Search state + private string searchInput = ""; + + // Pagination state + private int currentPage = 1; + private int pageSize = 10; + private int totalCount = 0; + private int totalPages = 0; + private int TotalPages => totalPages; + private int TotalCount => totalCount; + private IEnumerable PagedRoles => roleList; + + // Role form + private long editingId; + private string formName = ""; + private string? formDescription; + + // Inline access items in Create form + private List formAccessItems = new(); + private HashSet formSelectedAccessCodes = new(); + private string formAccessSearch = ""; + private bool isLoadingFormAccessItems = false; + + private bool IsAllFormAccessSelected + => formAccessItems.Count > 0 && formSelectedAccessCodes.Count == (formAccessItems.Count + formAccessItems.Sum(m => m.Permissions.Count)); + + // Access modal (for editing existing role) + private long selectedRoleId; + private string selectedRoleName = ""; + private List accessItems = new(); + private HashSet selectedAccessCodes = new(); + private string accessSearch = ""; + private bool isLoadingAccessItems = false; + private bool isSavingAccess = false; + private string? accessSaveError; + private string? accessSaveSuccess; + + private bool IsAllAccessSelected + => accessItems.Count > 0 && selectedAccessCodes.Count == (accessItems.Count + accessItems.Sum(m => m.Permissions.Count)); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + canCreateRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Create"); + canEditRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Update"); + canDeleteRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Delete"); + canManageAccess = canEditRole; + await LoadRoles(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadRoles() + { + isLoading = true; + var client = ApiClient.CreateClient(); + try + { + var rolesTask = client.GetFromJsonAsync>(BuildRolesUrl(), Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + await Task.WhenAll(rolesTask, usersTask); + roleList = rolesTask.Result?.Items ?? new(); + totalCount = rolesTask.Result?.TotalCount ?? 0; + totalPages = rolesTask.Result?.TotalPages ?? 0; + currentPage = rolesTask.Result?.Page ?? currentPage; + pageSize = rolesTask.Result?.PageSize ?? pageSize; + userList = usersTask.Result ?? new(); + } + catch (Exception ex) + { + Console.WriteLine($"Load roles error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + // Pagination handlers + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadRoles(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadRoles(); + } + + private async Task ApplySearch() + { + currentPage = 1; + await LoadRoles(); + } + + private async Task ResetSearch() + { + searchInput = ""; + currentPage = 1; + await LoadRoles(); + } + + private string BuildRolesUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + return $"Role?{string.Join("&", query)}"; + } + + // ===== Role CRUD ===== + private async Task OpenCreateModal() + { + isEditing = false; + editingId = 0; + formName = ""; + formDescription = null; + roleError = null; + isRoleNameError = false; + roleNameErrorMsg = ""; + formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase); + formAccessItems = new(); + formAccessSearch = ""; + isLoadingFormAccessItems = true; + showRoleModal = true; + StateHasChanged(); + + try + { + var client = ApiClient.CreateClient(); + formAccessItems = await client.GetFromJsonAsync>("Menu/access-items", Serialization.CaseInsensitive) ?? new(); + } + catch (Exception ex) + { + Console.WriteLine($"Load form menus error: {ex.Message}"); + } + finally + { + isLoadingFormAccessItems = false; + StateHasChanged(); + } + } + + private void OpenEditModal(RoleDto role) + { + isEditing = true; + editingId = role.Id; + formName = role.Name; + formDescription = role.Description; + roleError = null; + isRoleNameError = false; + roleNameErrorMsg = ""; + showRoleModal = true; + } + + private void CloseRoleModal() + { + showRoleModal = false; + roleError = null; + isRoleNameError = false; + roleNameErrorMsg = ""; + formAccessItems = new(); + formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase); + formAccessSearch = ""; + } + + private void ToggleAccessSelection(AccessMenuDto menu, bool isChecked, bool isForm) + { + var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes; + var allMenuSource = isForm ? formAccessItems : accessItems; + + if (isChecked) + { + AddMenuSelection(menu, allMenuSource, targetSet); + } + else + { + RemoveMenuSelection(menu, allMenuSource, targetSet); + } + StateHasChanged(); + } + + private void TogglePermissionSelection(string actionCode, bool isChecked, bool isForm) + { + var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes; + var menuSource = isForm ? formAccessItems : accessItems; + if (isChecked) + { + targetSet.Add(actionCode); + var permission = FindPermission(actionCode, menuSource); + if (permission != null) + { + EnsureMenuPathSelected(permission.ParentMenuCode, menuSource, targetSet); + EnsureMenuBranchVisible(permission.ParentMenuCode, menuSource, targetSet); + } + } + else + { + targetSet.Remove(actionCode); + } + StateHasChanged(); + } + + private void ToggleSelectAllFormAccess(ChangeEventArgs e) + { + var selectAll = (bool)(e.Value ?? false); + if (selectAll) + { + formSelectedAccessCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var menu in formAccessItems) + { + formSelectedAccessCodes.Add(menu.MenuCode); + foreach (var action in menu.Permissions) + { + formSelectedAccessCodes.Add(action.PermissionCode); + } + } + } + else + { + formSelectedAccessCodes.Clear(); + } + } + + private static bool IsRootMenu(AccessMenuDto menu) + => string.IsNullOrWhiteSpace(menu.ParentCode) || menu.ParentCode == "0"; + + private static bool MatchesSearch(string? value, string search) + => !string.IsNullOrWhiteSpace(value) + && value.Contains(search, StringComparison.OrdinalIgnoreCase); + + private IEnumerable GetRootAccessItems(bool isForm) + { + var source = isForm ? formAccessItems : accessItems; + var search = (isForm ? formAccessSearch : accessSearch).Trim(); + + return source + .Where(IsRootMenu) + .Where(menu => string.IsNullOrWhiteSpace(search) || AccessBranchMatchesSearch(menu, source, search)) + .OrderBy(m => m.OrderNo); + } + + private IEnumerable GetChildAccessItems(AccessMenuDto parent, bool isForm) + { + var source = isForm ? formAccessItems : accessItems; + var search = (isForm ? formAccessSearch : accessSearch).Trim(); + + var children = source + .Where(c => c.ParentCode == parent.MenuCode) + .OrderBy(c => c.OrderNo); + + if (string.IsNullOrWhiteSpace(search)) + { + return children; + } + + return children.Where(child => AccessBranchMatchesSearch(child, source, search)); + } + + private static bool AccessBranchMatchesSearch(AccessMenuDto menu, IReadOnlyList source, string search) + { + if (MatchesSearch(menu.MenuName, search) || MatchesSearch(menu.MenuCode, search) || MatchesSearch(menu.MenuUrl, search)) + { + return true; + } + + if (menu.Permissions.Any(permission => + MatchesSearch(permission.ActionName, search) || + MatchesSearch(permission.PermissionCode, search) || + MatchesSearch(permission.ApiName, search))) + { + return true; + } + + return source + .Where(child => child.ParentCode == menu.MenuCode) + .Any(child => AccessBranchMatchesSearch(child, source, search)); + } + + private static AccessPermissionDto? FindPermission(string actionCode, IReadOnlyList source) + { + foreach (var menu in source) + { + var permission = menu.Permissions.FirstOrDefault(p => string.Equals(p.PermissionCode, actionCode, StringComparison.OrdinalIgnoreCase)); + if (permission != null) + { + return permission; + } + } + + return null; + } + + private static AccessMenuDto? FindMenu(string menuCode, IReadOnlyList source) + { + return source.FirstOrDefault(m => string.Equals(m.MenuCode, menuCode, StringComparison.OrdinalIgnoreCase)); + } + + private static void EnsureMenuPathSelected(string menuCode, IReadOnlyList source, ISet targetSet) + { + var current = FindMenu(menuCode, source); + while (current != null) + { + targetSet.Add(current.MenuCode); + if (string.IsNullOrWhiteSpace(current.ParentCode) || current.ParentCode == "0") + { + break; + } + + current = FindMenu(current.ParentCode, source); + } + } + + private static void EnsureMenuBranchVisible(string menuCode, IReadOnlyList source, ISet targetSet) + { + var menu = FindMenu(menuCode, source); + if (menu == null) + { + return; + } + + targetSet.Add(menu.MenuCode); + + foreach (var child in GetAutoVisibleChildren(menu, source)) + { + EnsureMenuBranchVisible(child.MenuCode, source, targetSet); + } + } + + private static void AddMenuSelection(AccessMenuDto menu, IReadOnlyList source, ISet targetSet) + { + targetSet.Add(menu.MenuCode); + foreach (var permission in menu.Permissions) + { + targetSet.Add(permission.PermissionCode); + } + + EnsureMenuPathSelected(menu.ParentCode, source, targetSet); + + foreach (var child in GetAutoVisibleChildren(menu, source)) + { + EnsureMenuBranchVisible(child.MenuCode, source, targetSet); + } + } + + private static void RemoveMenuSelection(AccessMenuDto menu, IReadOnlyList source, ISet targetSet) + { + targetSet.Remove(menu.MenuCode); + foreach (var permission in menu.Permissions) + { + targetSet.Remove(permission.PermissionCode); + } + + foreach (var child in GetAutoVisibleChildren(menu, source)) + { + RemoveMenuSelection(child, source, targetSet); + } + + CleanupAncestorSelection(menu.ParentCode, source, targetSet); + } + + private static void CleanupAncestorSelection(string parentCode, IReadOnlyList source, ISet targetSet) + { + if (string.IsNullOrWhiteSpace(parentCode) || parentCode == "0") + { + return; + } + + var parent = FindMenu(parentCode, source); + if (parent == null) + { + return; + } + + if (HasAnySelectedInBranch(parent, source, targetSet)) + { + return; + } + + targetSet.Remove(parent.MenuCode); + CleanupAncestorSelection(parent.ParentCode, source, targetSet); + } + + private static bool HasAnySelectedInBranch(AccessMenuDto menu, IReadOnlyList source, ISet targetSet) + { + if (targetSet.Contains(menu.MenuCode) || menu.Permissions.Any(p => targetSet.Contains(p.PermissionCode))) + { + return true; + } + + return GetAutoVisibleChildren(menu, source) + .Any(child => HasAnySelectedInBranch(child, source, targetSet)); + } + + private static void NormalizeAccessSelection(IReadOnlyList source, ISet targetSet) + { + var selectedPermissions = source + .SelectMany(menu => menu.Permissions) + .Where(permission => targetSet.Contains(permission.PermissionCode)) + .ToList(); + + foreach (var permission in selectedPermissions) + { + EnsureMenuPathSelected(permission.ParentMenuCode, source, targetSet); + EnsureMenuBranchVisible(permission.ParentMenuCode, source, targetSet); + } + } + + private static IEnumerable GetAutoVisibleChildren(AccessMenuDto menu, IReadOnlyList source) + { + return source + .Where(m => m.ParentCode == menu.MenuCode && IsAutoVisiblePageMenu(m)) + .OrderBy(m => m.OrderNo) + .ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase); + } + + private static bool IsAutoVisiblePageMenu(AccessMenuDto menu) + { + var text = $"{menu.MenuName} {menu.MenuCode} {menu.MenuUrl}".Trim(); + return text.Contains("list", StringComparison.OrdinalIgnoreCase) + || text.Contains("view", StringComparison.OrdinalIgnoreCase) + || text.Contains("dashboard", StringComparison.OrdinalIgnoreCase) + || text.Contains("home", StringComparison.OrdinalIgnoreCase); + } + + private void RequestSaveConfirmation() + { + isRoleNameError = false; + roleNameErrorMsg = ""; + roleError = null; + bool hasError = false; + + if (string.IsNullOrWhiteSpace(formName)) + { + isRoleNameError = true; + roleNameErrorMsg = ResultMessages.RoleNameRequired; + hasError = true; + } + + if (hasError) return; + + if (isEditing) + { + confirmTitle = "Update Role?"; + confirmMessage = "Are you sure you want to update this role?"; + confirmButtonText = "Update"; + confirmIcon = "pencil"; + confirmIconBg = "bg-amber-50"; + confirmIconText = "text-amber-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + else + { + confirmTitle = "Create Role?"; + confirmMessage = "Are you sure you want to create this role?"; + confirmButtonText = "Create"; + confirmIcon = "plus-circle"; + confirmIconBg = "bg-emerald-50"; + confirmIconText = "text-emerald-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + confirmAction = SaveRole; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = "Delete Role?"; + confirmMessage = "Are you sure you want to delete this role? This action cannot be undone."; + confirmButtonText = "Delete"; + confirmIcon = "trash-2"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + confirmAction = () => DeleteRole(id); + confirmHideCancel = false; + showConfirmDialog = true; + } + + private async Task HandleConfirmAction() + { + showConfirmDialog = false; + if (confirmAction != null) + await confirmAction(); + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + confirmHideCancel = false; + } + + private async Task SaveRole() + { + if (string.IsNullOrWhiteSpace(formName)) + { + return; + } + + isSaving = true; + roleError = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateRoleDto { Name = formName, Description = formDescription }; + var response = await client.PutAsJsonAsync($"Role/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToUpdateRole; return; } + } + else + { + NormalizeAccessSelection(formAccessItems, formSelectedAccessCodes); + var dto = new CreateRoleDto + { + Name = formName, + Description = formDescription, + AccessCodes = formSelectedAccessCodes.ToList() + }; + var response = await client.PostAsJsonAsync("Role", dto); + var res = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToCreateRole; return; } + } + CloseRoleModal(); + await LoadRoles(); + successMessage = isEditing ? "Role updated successfully!" : "Role created successfully!"; + } + catch (Exception ex) { roleError = ex.Message; } + finally { isSaving = false; } + } + + private async Task DeleteRole(long id) + { + var usersCount = userList.Count(u => u.RoleId == id); + if (usersCount > 0) + { + confirmTitle = "Cannot Delete Role"; + confirmMessage = $"you can't delete this role because there are {usersCount} users in this role."; + confirmButtonText = "OK"; + confirmIcon = "alert-circle"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-rose-600 hover:bg-rose-700"; + confirmAction = null; + confirmHideCancel = true; + showConfirmDialog = true; + StateHasChanged(); + return; + } + + var client = ApiClient.CreateClient(); + try + { + var response = await client.DeleteAsync($"Role/{id}"); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + confirmTitle = "Cannot Delete Role"; + confirmMessage = res?.ErrorMessage ?? "Failed to delete role."; + confirmButtonText = "OK"; + confirmIcon = "alert-circle"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-rose-600 hover:bg-rose-700"; + confirmAction = null; + confirmHideCancel = true; + showConfirmDialog = true; + StateHasChanged(); + return; + } + await LoadRoles(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = "Role deleted successfully!"; + } + catch (Exception ex) + { + Console.WriteLine($"Delete error: {ex.Message}"); + confirmTitle = "Error"; + confirmMessage = $"Delete error: {ex.Message}"; + confirmButtonText = "OK"; + confirmIcon = "alert-circle"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-rose-600 hover:bg-rose-700"; + confirmAction = null; + confirmHideCancel = true; + showConfirmDialog = true; + StateHasChanged(); + } + } + + // ===== Access Management ===== + private async Task OpenAccessModal(RoleDto role) + { + selectedRoleId = role.Id; + selectedRoleName = role.Name; + accessSaveError = null; + accessSaveSuccess = null; + accessSearch = ""; + isLoadingAccessItems = true; + showAccessModal = true; + StateHasChanged(); + + var client = ApiClient.CreateClient(); + try + { + // Load all available access items with their nested permissions + accessItems = await client.GetFromJsonAsync>("Menu/access-items", Serialization.CaseInsensitive) ?? new(); + + // Load currently assigned access items for this role + var assignedResult = await client.GetFromJsonAsync>>($"Role/{role.Id}/access", Serialization.CaseInsensitive); + selectedAccessCodes = new HashSet(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase); + NormalizeAccessSelection(accessItems, selectedAccessCodes); + } + catch (Exception ex) + { + Console.WriteLine($"Load access items error: {ex.Message}"); + accessSaveError = "Failed to load access items. Please try again."; + } + finally + { + isLoadingAccessItems = false; + StateHasChanged(); + } + } + + private void CloseAccessModal() + { + showAccessModal = false; + accessItems = new(); + selectedAccessCodes = new(StringComparer.OrdinalIgnoreCase); + accessSearch = ""; + accessSaveError = null; + accessSaveSuccess = null; + } + + private void ToggleSelectAllAccess(ChangeEventArgs e) + { + var selectAll = (bool)(e.Value ?? false); + if (selectAll) + { + selectedAccessCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var menu in accessItems) + { + selectedAccessCodes.Add(menu.MenuCode); + foreach (var action in menu.Permissions) + { + selectedAccessCodes.Add(action.PermissionCode); + } + } + } + else + { + selectedAccessCodes.Clear(); + } + } + + private async Task SaveAccess() + { + isSavingAccess = true; + accessSaveError = null; + accessSaveSuccess = null; + var client = ApiClient.CreateClient(); + try + { + NormalizeAccessSelection(accessItems, selectedAccessCodes); + var dto = new AssignAccessDto { AccessCodes = selectedAccessCodes.ToList() }; + var response = await client.PostAsJsonAsync($"Role/{selectedRoleId}/access", dto); + if (response.IsSuccessStatusCode) + { + accessSaveSuccess = $"Access for '{selectedRoleName}' saved successfully!"; + } + else + { + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + accessSaveError = $"Failed to save access. Server returned {(int)response.StatusCode}."; + } + else + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + accessSaveError = doc.RootElement.TryGetProperty("message", out var message) + ? message.GetString() + : body; + } + catch + { + accessSaveError = body; + } + } + } + } + catch (Exception ex) + { + accessSaveError = ex.Message; + } + finally + { + isSavingAccess = false; + StateHasChanged(); + } + } +} + + + diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/KanbanBoard.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/KanbanBoard.razor new file mode 100644 index 0000000000000000000000000000000000000000..47c14bded5ad28402ce30c38a31e97ed7ec0db73 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/KanbanBoard.razor @@ -0,0 +1,614 @@ +@page "/board" +@page "/projects/{ProjectId:long}/tasks" +@using Microsoft.AspNetCore.Authorization +@using TaskTrackingSystem.Shared.Models.Project +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject NavigationManager Navigation +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +Kanban Board + + + +
+ +
+
+ @if (ProjectId > 0) + { + + } +
+

Kanban Board

+

@(project != null ? project.Name : (ProjectId > 0 ? $"Project #{ProjectId}" : "All Projects"))

+
+
+
+ + + @if (canUpdateTask && !showArchivedTasks) + { + + } + @if (canCreateTask && !showArchivedTasks) + { + + } +
+
+ + @if (!string.IsNullOrWhiteSpace(boardError)) + { +
+ @boardError +
+ } + + @if (isLoading) + { +
+
+ + Loading board... +
+
+ } + else + { + +
+ @(showArchivedTasks ? "Viewing archived tasks." : "Drag tasks between columns to update their status.") +
+
+ @foreach (var column in VisibleColumns) + { + var tasksInColumn = VisibleTasks.Where(t => t.StatusId == column.StatusId).ToList(); +
+ +
+
+ +

@column.Name

+
+ @tasksInColumn.Count +
+ + +
+ @if (tasksInColumn.Any()) + { + @foreach (var task in tasksInColumn) + { +
+
+

@task.Title

+ @GetPriorityLabel(task.PriorityId) +
+ @if (!string.IsNullOrEmpty(task.Description)) + { +

@task.Description

+ } +
+ Due @DisplayFormats.Date(task.DueDate) + @if (showArchivedTasks) + { + Archived + } +
+
+ } + } + else + { +
+

Drop tasks here

+
+ } +
+
+ } +
+ } +
+ +@* ===== Create Task Modal ===== *@ +@if (showTaskModal) +{ +
+
+
+

New Task

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(taskError)) + { +
+

@taskError

+
+ } +
+
+ + +
+
+
+} + +@* ===== Confirm Dialog ===== *@ + + +@code { + [Parameter] public long ProjectId { get; set; } + + private bool isLoading = true; + private bool isAdmin = false; + private bool isManager = false; + private bool canCreateTask = false; + private bool canUpdateTask = false; + private bool showArchivedTasks = false; + private long currentUserId = 0; + private ProjectDto? project; + private bool showTaskModal = false; + private bool isSaving = false; + private string? taskError; + private string? boardError; + + // Confirm dialog state + private bool showConfirmDialog = false; + private string? successMessage; + private string confirmTitle = ""; + private string confirmMessage = ""; + private string confirmButtonText = ""; + private string confirmIcon = ""; + private string confirmIconBg = ""; + private string confirmIconText = ""; + private string confirmButtonClass = ""; + private Func? confirmAction; + private List tasks = new(); + private List projects = new(); + private long selectedProjectId = 0; + + // Task form + private long taskProjectId; + private string taskTitle = ""; + private string? taskDescription; + private TaskPriority taskPriorityId = TaskPriority.Medium; + private DateTime taskDueDate = DateTime.Today.AddDays(7); + + private List columns = new() + { + new(AppTaskStatus.Todo, "To Do", "bg-slate-400"), + new(AppTaskStatus.InProgress, "In Progress", "bg-blue-500"), + new(AppTaskStatus.Done, "Done", "bg-emerald-500") + }; + + private TaskDto? draggedTask; + private AppTaskStatus? dropTargetStatus; + + private IEnumerable VisibleTasks => + selectedProjectId > 0 + ? tasks.Where(t => t.ProjectId == selectedProjectId) + : tasks; + + private IEnumerable VisibleColumns => + showArchivedTasks + ? columns.Where(column => column.StatusId == AppTaskStatus.Done) + : columns; + + private Task HandleDragStart(TaskDto task) + { + if (!canUpdateTask) + { + return Task.CompletedTask; + } + + if (showArchivedTasks) + { + return Task.CompletedTask; + } + + draggedTask = task; + dropTargetStatus = null; + return Task.CompletedTask; + } + + private void HandleDragOver() + { + // Allow dropping + } + + private void HandleDragEnter(AppTaskStatus statusId) + { + if (draggedTask != null) + { + dropTargetStatus = statusId; + } + } + + private void HandleDragLeave() + { + dropTargetStatus = null; + } + + private async Task HandleDrop(AppTaskStatus newStatusId) + { + var taskToMove = draggedTask; + + if (taskToMove != null && taskToMove.StatusId != newStatusId) + { + await MoveTask(taskToMove.Id, newStatusId); + } + + draggedTask = null; + dropTargetStatus = null; + } + + private Task HandleDragEnd() + { + draggedTask = null; + dropTargetStatus = null; + return Task.CompletedTask; + } + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + isManager = user.IsInRole("Manager"); + canCreateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Create"); + canUpdateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Update"); + selectedProjectId = ProjectId > 0 ? ProjectId : 0; + await LoadTasks(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadTasks() + { + isLoading = true; + var client = ApiClient.CreateClient(); + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + isManager = user.IsInRole("Manager"); + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) long.TryParse(idStr, out currentUserId); + + var tasksTask = client.GetFromJsonAsync>(showArchivedTasks ? "Task/archived" : "Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + await System.Threading.Tasks.Task.WhenAll(tasksTask, projectsTask); + + var allTasks = tasksTask.Result ?? new(); + projects = projectsTask.Result ?? new(); + project = selectedProjectId > 0 ? projects.FirstOrDefault(p => p.Id == selectedProjectId) : null; + + // Filter for non-admins and non-managers + tasks = (!(isAdmin || isManager) && currentUserId > 0) + ? allTasks.Where(t => t.AssignedTo == currentUserId).ToList() + : allTasks; + } + catch (Exception ex) + { + Console.WriteLine($"Load tasks error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private async Task MoveTask(long taskId, AppTaskStatus newStatusId) + { + if (!canUpdateTask) + { + return; + } + + var client = ApiClient.CreateClient(); + var task = tasks.FirstOrDefault(t => t.Id == taskId); + var previousStatus = task?.StatusId; + + if (task != null) + { + task.StatusId = newStatusId; + boardError = null; + StateHasChanged(); + } + + try + { + var response = await client.PatchAsync($"Task/{taskId}/status?statusId={(long)newStatusId}", null); + if (!response.IsSuccessStatusCode) + { + if (task != null && previousStatus.HasValue) + { + task.StatusId = previousStatus.Value; + } + boardError = "Failed to move task. Please try again."; + } + } + catch (Exception ex) + { + if (task != null && previousStatus.HasValue) + { + task.StatusId = previousStatus.Value; + } + await LoadTasks(); + Console.WriteLine($"Move task error: {ex.Message}"); + boardError = "Failed to move task. Please try again."; + } + } + + private void GoBack() => Navigation.NavigateTo("/projects/all"); + + private async Task ToggleArchivedView() + { + showArchivedTasks = !showArchivedTasks; + draggedTask = null; + dropTargetStatus = null; + boardError = null; + await LoadTasks(); + } + + private void OnProjectFilterChanged(ChangeEventArgs e) + { + selectedProjectId = long.TryParse(e.Value?.ToString(), out var value) ? value : 0; + project = selectedProjectId > 0 ? projects.FirstOrDefault(p => p.Id == selectedProjectId) : null; + taskProjectId = selectedProjectId; + boardError = null; + } + + private void OpenCreateTaskModal() + { + taskProjectId = selectedProjectId > 0 ? selectedProjectId : projects.FirstOrDefault()?.Id ?? 0; + taskTitle = ""; + taskDescription = null; + taskPriorityId = TaskPriority.Medium; + taskDueDate = DateTime.Today.AddDays(7); + taskError = null; + showTaskModal = true; + } + + private void CloseTaskModal() + { + showTaskModal = false; + taskError = null; + } + + private void RequestCreateConfirmation() + { + if (string.IsNullOrWhiteSpace(taskTitle)) + { + taskError = "Task title is required."; + return; + } + if (taskProjectId == 0) + { + taskError = "Please select a project."; + return; + } + + confirmTitle = "Create Task?"; + confirmMessage = "Are you sure you want to create this task?"; + confirmButtonText = "Create"; + confirmIcon = "plus-circle"; + confirmIconBg = "bg-emerald-50"; + confirmIconText = "text-emerald-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + confirmAction = CreateTask; + showConfirmDialog = true; + } + + private async Task HandleConfirmAction() + { + showConfirmDialog = false; + if (confirmAction != null) + await confirmAction(); + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task CreateTask() + { + if (string.IsNullOrWhiteSpace(taskTitle)) + { + taskError = "Task title is required."; + return; + } + + isSaving = true; + taskError = null; + var client = ApiClient.CreateClient(); + + try + { + var dto = new CreateTaskDto + { + Title = taskTitle, + Description = taskDescription, + ProjectId = taskProjectId, + StatusId = AppTaskStatus.Todo, + PriorityId = taskPriorityId, + DueDate = taskDueDate + }; + var response = await client.PostAsJsonAsync("Task", dto); + if (response.IsSuccessStatusCode) + { + CloseTaskModal(); + await LoadTasks(); + selectedProjectId = taskProjectId; + project = projects.FirstOrDefault(p => p.Id == selectedProjectId); + successMessage = "Task created successfully!"; + } + else + { + taskError = "Failed to create task."; + } + } + catch (Exception ex) + { + taskError = ex.Message; + } + finally + { + isSaving = false; + } + } + + private void RequestArchiveDoneConfirmation() + { + var doneCount = VisibleTasks.Count(t => t.StatusId == AppTaskStatus.Done); + if (doneCount == 0) + { + successMessage = "No Done tasks to archive."; + return; + } + + confirmTitle = "Archive Done Tasks?"; + confirmMessage = selectedProjectId > 0 + ? $"Archive {doneCount} Done task(s) in this project? Archived tasks will be hidden from active views." + : $"Archive {doneCount} Done task(s) across all visible projects? Archived tasks will be hidden from active views."; + confirmButtonText = "Archive"; + confirmIcon = "archive"; + confirmIconBg = "bg-slate-100"; + confirmIconText = "text-slate-600"; + confirmButtonClass = "bg-slate-900 hover:bg-slate-800"; + confirmAction = ArchiveDoneTasks; + showConfirmDialog = true; + } + + private async Task ArchiveDoneTasks() + { + var client = ApiClient.CreateClient(); + boardError = null; + + var url = selectedProjectId > 0 + ? $"Task/archive-done?projectId={selectedProjectId}" + : "Task/archive-done"; + + try + { + var response = await client.PatchAsync(url, null); + var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (response.IsSuccessStatusCode && result?.IsSuccess == true) + { + await LoadTasks(); + successMessage = result.Value == 1 + ? "Archived 1 Done task." + : $"Archived {result?.Value ?? 0} Done tasks."; + return; + } + + boardError = result?.ErrorMessage ?? "Failed to archive Done tasks."; + } + catch (Exception ex) + { + Console.WriteLine($"Archive done tasks error: {ex.Message}"); + boardError = "Failed to archive Done tasks."; + } + } + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-100 text-amber-700", + TaskPriority.High => "bg-red-100 text-red-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "Low", + TaskPriority.Medium => "Medium", + TaskPriority.High => "High", + _ => "-" + }; + + private record KanbanColumn(AppTaskStatus StatusId, string Name, string DotColor); +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskAssign.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskAssign.razor new file mode 100644 index 0000000000000000000000000000000000000000..43fc062a5251f67cc4ac795122e1482f69eba80a --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskAssign.razor @@ -0,0 +1,557 @@ +@page "/tasks/assign" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Role +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.PageTitle("taskAssignment", "Task Assignment") + + + +
+ +
+
+

@AppLocalization.PageTitle("taskAssignment", "Task Assignment")

+

Assign tasks to project members and track task owners.

+
+
+ + @if (isLoading) + { + + } + else + { + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+

Tasks

+
+
+ + +
+ + +
+
+
+ @if (FilteredTasks.Any()) + { + @foreach (var task in FilteredTasks) + { + var isSelected = SelectedTask?.Id == task.Id; + var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId); + + } + } + else + { +
@AppLocalization.Text("common.noTasksFound", "No tasks found.")
+ } +
+
+ + +
+ @if (SelectedTask == null) + { +
+
+ +
+

No Task Selected

+

@AppLocalization.Text("task.selectTaskPrompt", "Select a task from the left panel to assign it to a team member.")

+
+ } + else + { + +
+
+ @AppLocalization.Text("common.currentlyAssigningTask", "Currently Assigning Task") + TSK-@SelectedTask.Id +
+

@SelectedTask.Title

+ @if (!string.IsNullOrEmpty(SelectedTask.Description)) + { +

@SelectedTask.Description

+ } + +
+ + Status: @GetStatusLabel(SelectedTask.StatusId) + + + Priority: @GetPriorityLabel(SelectedTask.PriorityId) + + + + Due: @DisplayFormats.Date(SelectedTask.DueDate) + +
+
+ + +
+
+
+ + +
+ + +
+ @if (isAdminUser) + { +
+ +
+ } + else + { +
+ Employees in this project +
+ } +
+ @AppLocalization.Text("common.selected", "Selected"): @(TargetAssigneeId.HasValue ? allUsers.FirstOrDefault(u => u.Id == TargetAssigneeId)?.Username ?? AppLocalization.Text("common.selected", "Selected") : AppLocalization.Text("common.unassigned", "Unassigned")) +
+
+ + +
+
+ + + + @foreach (var user in FilteredUsers) + { + var isSelected = TargetAssigneeId == user.Id; + + } +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(statusMessage)) + { +

+ + @statusMessage +

+ } +
+ @if (canSaveAssignments) + { + + } +
+ } +
+
+ } +
+ + + +@code { + private bool isLoading = true; + private bool isSaving = false; + private bool canSaveAssignments = false; + private bool showConfirmDialog = false; + private string? statusMessage; + private string? successMessage; + private bool isError = false; + + private List tasks = new(); + private List projects = new(); + private List allUsers = new(); + private List projectMembers = new(); + private List roles = new(); + private long? adminRoleId; + private long? employeeRoleId; + private bool isAdminUser; + + private long selectedProjectId = 0; + private long selectedProjectIdInput = 0; + private long selectedRoleId = 0; + private string taskSearchInput = ""; + private string taskSearch = ""; + private string userSearchInput = ""; + private string userSearch = ""; + + private TaskDto? SelectedTask { get; set; } + private long? TargetAssigneeId { get; set; } + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + isAdminUser = authState.User.IsInRole("Admin"); + canSaveAssignments = authState.User.IsInRole("Admin") || await MenuAuthorization.HasAccessCodeAsync(authState.User, "Tasks_Update"); + var client = ApiClient.CreateClient(); + try + { + var tasksTask = client.GetFromJsonAsync>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + var rolesTask = client.GetFromJsonAsync>("Role", Serialization.CaseInsensitive); + await Task.WhenAll(tasksTask, projectsTask, usersTask, rolesTask); + + tasks = tasksTask.Result ?? new(); + projects = projectsTask.Result ?? new(); + allUsers = usersTask.Result ?? new(); + + var allLoadedRoles = rolesTask.Result ?? new(); + var adminRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)); + var employeeRole = allLoadedRoles.FirstOrDefault(r => string.Equals(r.Name, "Employee", StringComparison.OrdinalIgnoreCase)); + adminRoleId = adminRole?.Id; + employeeRoleId = employeeRole?.Id; + roles = allLoadedRoles.Where(r => !string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase)).ToList(); + if (!isAdminUser && employeeRoleId.HasValue) + { + selectedRoleId = employeeRoleId.Value; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading initial data: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private IEnumerable FilteredTasks + { + get + { + var result = tasks.AsEnumerable(); + if (selectedProjectId > 0) + { + result = result.Where(t => t.ProjectId == selectedProjectId); + } + if (!string.IsNullOrWhiteSpace(taskSearch)) + { + result = result.Where(t => t.Title.Contains(taskSearch, StringComparison.OrdinalIgnoreCase) + || (t.Description != null && t.Description.Contains(taskSearch, StringComparison.OrdinalIgnoreCase))); + } + return result; + } + } + + private IEnumerable FilteredUsers + { + get + { + var result = projectMembers.Where(u => u.IsActive); + if (adminRoleId.HasValue) + { + result = result.Where(u => u.RoleId != adminRoleId.Value); + } + if (isAdminUser && selectedRoleId > 0) + { + result = result.Where(u => u.RoleId == selectedRoleId); + } + else if (!isAdminUser && employeeRoleId.HasValue) + { + result = result.Where(u => u.RoleId == employeeRoleId.Value); + } + if (!string.IsNullOrWhiteSpace(userSearch)) + { + result = result.Where(u => $"{u.FirstName} {u.LastName}".Contains(userSearch, StringComparison.OrdinalIgnoreCase) + || u.Username.Contains(userSearch, StringComparison.OrdinalIgnoreCase)); + } + return result; + } + } + + private void ApplyTaskSearch() + { + taskSearch = taskSearchInput; + } + + private void ApplyUserSearch() + { + userSearch = userSearchInput; + } + + private void ApplyProjectFilter() + { + selectedProjectId = selectedProjectIdInput; + SelectedTask = null; + TargetAssigneeId = null; + projectMembers.Clear(); + statusMessage = null; + } + + private void ResetProjectFilter() + { + selectedProjectIdInput = 0; + selectedProjectId = 0; + SelectedTask = null; + TargetAssigneeId = null; + projectMembers.Clear(); + statusMessage = null; + } + + private void ResetTaskSearch() + { + taskSearchInput = ""; + taskSearch = ""; + } + + private void ResetUserSearch() + { + userSearchInput = ""; + userSearch = ""; + selectedRoleId = 0; + } + + private async Task SelectTask(TaskDto task) + { + SelectedTask = task; + TargetAssigneeId = task.AssignedTo; + statusMessage = null; + await LoadProjectMembersAsync(task.ProjectId); + } + + private void SetTargetAssignee(long? userId) + { + TargetAssigneeId = userId; + statusMessage = null; + } + + private void RequestSaveConfirmation() + { + if (SelectedTask == null) return; + showConfirmDialog = true; + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task ConfirmSaveAssignment() + { + showConfirmDialog = false; + await SaveAssignment(); + } + + private async Task LoadProjectMembersAsync(long projectId) + { + projectMembers.Clear(); + + if (projectId <= 0) + { + return; + } + + var client = ApiClient.CreateClient(); + try + { + var membersResult = await client.GetFromJsonAsync>>($"Project/{projectId}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + projectMembers = membersResult.Value.ToList(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading project members: {ex.Message}"); + statusMessage = "Failed to load project members."; + isError = true; + } + finally + { + await InvokeAsync(StateHasChanged); + await JS.InvokeVoidAsync("initIcons"); + } + } + + private async Task SaveAssignment() + { + if (SelectedTask == null) return; + + isSaving = true; + statusMessage = null; + var client = ApiClient.CreateClient(); + + try + { + var dto = new UpdateTaskDto + { + Title = SelectedTask.Title, + Description = SelectedTask.Description, + StatusId = SelectedTask.StatusId, + PriorityId = SelectedTask.PriorityId, + DueDate = SelectedTask.DueDate, + AssignedTo = TargetAssigneeId + }; + + var response = await client.PutAsJsonAsync($"Task/{SelectedTask.Id}", dto); + if (response.IsSuccessStatusCode) + { + SelectedTask.AssignedTo = TargetAssigneeId; + statusMessage = "Task assignee updated successfully!"; + successMessage = "Assignment saved successfully!"; + isError = false; + Navigation.NavigateTo("/tasks/all"); + } + else + { + statusMessage = "Failed to update assignee."; + isError = true; + } + } + catch (Exception ex) + { + statusMessage = $"Error: {ex.Message}"; + isError = true; + } + finally + { + isSaving = false; + await JS.InvokeVoidAsync("initIcons"); + } + } + + private string GetStatusBadge(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "bg-slate-100 text-slate-700", + AppTaskStatus.InProgress => "bg-blue-100 text-blue-700", + AppTaskStatus.Done => "bg-emerald-100 text-emerald-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => "Unknown" + }; + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-100 text-amber-700", + TaskPriority.High => "bg-red-100 text-red-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "Low", + TaskPriority.Medium => "Medium", + TaskPriority.High => "High", + _ => "-" + }; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskDetails.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskDetails.razor new file mode 100644 index 0000000000000000000000000000000000000000..e154540c766fe260abd219759d4c0ed40a73e40b --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskDetails.razor @@ -0,0 +1,715 @@ +@page "/tasks/{TaskId:long}/details" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.Comment +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.WebApp.Components.Partial +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +Task Details + +
+ @if (ViewMode != "Embedded") + { + +
+
+ Workspace + + Tasks + + Task Details +
+ + + Back to Tasks + +
+ } + + @if (isLoading) + { + + } + else if (task == null) + { +
+ +

Task Not Found

+

The task you are looking for does not exist or has been deleted.

+
+ } + else + { + +
+
+
+
+ TASK-#@task.Id + + @GetStatusLabel(task.StatusId) + + + @GetPriorityLabel(task.PriorityId) + + @if (project != null) + { + + + @project.Name + + } +
+ @if (isEditingTitle) + { +
+ + + +
+ } + else + { +

+ @task.Title + @if (hasTaskManagePermission) + { + + } +

+ } +
+
+
+ +
+ +
+ +
+
+

+ Description +

+ @if (hasTaskManagePermission && !isEditingDescription) + { + + } +
+ @if (isEditingDescription) + { +
+ +
+ + +
+
+ } + else + { +
+ @(string.IsNullOrWhiteSpace(task.Description) ? "No description provided for this task." : task.Description) +
+ } +
+ + +
+

+ Properties +

+
+ +
+ Status + +
+ + +
+ Priority + @if (hasTaskManagePermission) + { + + } + else + { +
+ + @GetPriorityLabel(task.PriorityId) + +
+ } +
+ + +
+ Assignee + @if (hasTaskManagePermission) + { + + } + else + { +
+ @if (assignee != null) + { + + @(assignee.FirstName.Length > 0 ? assignee.FirstName[0].ToString().ToUpper() : "") + + @assignee.FirstName @assignee.LastName + } + else + { + Unassigned + } +
+ } +
+ + +
+ Due Date + @if (hasTaskManagePermission) + { + + } + else + { +
+ + @DisplayFormats.Date(task.DueDate) +
+ } +
+ + +
+ Created At +
+ + @task.CreatedAt.ToString("dd-MM-yyyy HH:mm") +
+
+
+
+
+ + +
+
+ +
+

+ + Comments (@comments.Count) +

+
+ + +
+
+ @if (comments.Any()) + { + @foreach (var c in comments) + { +
+ + @(c.UserFullName.Length > 0 ? c.UserFullName[0].ToString().ToUpper() : "U") + +
+
+ @c.UserFullName + @c.CreatedAt.ToString("g") +
+

@c.UserRoleName

+
@c.Message
+
+ @if (c.UserId == currentUserId || isAdmin) + { + + } +
+ } + } + else + { +
+ +

No comments posted yet.

+

Start the conversation below!

+
+ } +
+
+ + +
+
+
+ + @if (!string.IsNullOrEmpty(commentError)) + { +

@commentError

+ } +
+ +
+
+
+
+
+ } +
+ + + +@code { + [Parameter] public long TaskId { get; set; } + [Parameter] public string? ViewMode { get; set; } + [Parameter] public EventCallback OnTaskUpdated { get; set; } + [SupplyParameterFromQuery(Name = "tab")] + public string? TabQuery { get; set; } + private Func? confirmAction; + + private bool isLoading = true; + private TaskDto? task; + private ProjectDto? project; + private UserDto? assignee; + private List comments = new(); + private List allUsers = new(); + + private bool isAdmin = false; + private bool canEditTask = false; + private bool hasTaskManagePermission = false; + private long currentUserId = 0; + private string userRoleName = "Employee"; + + private List scopes = new(); + + // Comments Form + private string newCommentMessage = ""; + private bool isSavingComment = false; + private string? commentError; + + // Confirm dialog + private bool showConfirm = false; + private string confirmTitle = ""; + private string confirmMessage = ""; + + // Inline edit states + private bool isEditingDescription = false; + private bool isSavingDescription = false; + private string tempDescription = ""; + + private bool isEditingTitle = false; + private bool isSavingTitle = false; + private string tempTitle = ""; + + private long lastLoadedTaskId = 0; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + + // Task details editing is driven by role permissions so future roles can be granted access. + canEditTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Update"); + hasTaskManagePermission = canEditTask; + + var roleClaim = user.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value; + userRoleName = roleClaim ?? "Employee"; + + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) long.TryParse(idStr, out currentUserId); + } + + protected override async Task OnParametersSetAsync() + { + if (TaskId > 0 && TaskId != lastLoadedTaskId) + { + lastLoadedTaskId = TaskId; + assignee = null; + await LoadAllData(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task LoadAllData() + { + isLoading = true; + var client = ApiClient.CreateClient(); + try + { + var taskUrl = $"Task/{TaskId}"; + var commentsUrl = $"TaskDetails/tasks/{TaskId}/comments"; + var usersUrl = "User"; + + var taskResponseTask = client.GetAsync(taskUrl); + var commentsResponseTask = client.GetAsync(commentsUrl); + var usersResponseTask = client.GetAsync(usersUrl); + + await Task.WhenAll(taskResponseTask, commentsResponseTask, usersResponseTask); + + var taskResponse = taskResponseTask.Result; + if (taskResponse.IsSuccessStatusCode) + { + task = await taskResponse.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + } + + var commentsResponse = commentsResponseTask.Result; + if (commentsResponse.IsSuccessStatusCode) + { + comments = await commentsResponse.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive) ?? new(); + } + + var usersResponse = usersResponseTask.Result; + if (usersResponse.IsSuccessStatusCode) + { + allUsers = await usersResponse.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive) ?? new(); + } + + if (task != null) + { + // Fetch project details + var projResponse = await client.GetAsync($"Project/{task.ProjectId}"); + if (projResponse.IsSuccessStatusCode) + { + project = await projResponse.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + } + + // Set assignee details + if (task.AssignedTo.HasValue) + { + assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo.Value); + } + + CalculateScopes(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Load task details error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private void CalculateScopes() + { + scopes = new List(); + + if (task == null) return; + + if (hasTaskManagePermission) + { + scopes.Add("task:all"); + scopes.Add("task:write"); + scopes.Add("comment:write"); + } + else + { + scopes.Add("task:read"); + // Comments stay available to everyone who can view the task. + scopes.Add("comment:write"); + } + } + + // ─── INLINE MUTATIONS ────────────────────────────────────────────────────── + + private async Task OnStatusChanged(ChangeEventArgs e) + { + if (task == null) return; + if (Enum.TryParse(e.Value?.ToString(), out var newStatus)) + { + var client = ApiClient.CreateClient(); + try + { + var response = await client.PatchAsync($"Task/{task.Id}/status?statusId={(int)newStatus}", null); + if (response.IsSuccessStatusCode) + { + task.StatusId = newStatus; + await OnTaskUpdated.InvokeAsync(); + await JS.InvokeVoidAsync("initIcons"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Update status error: {ex.Message}"); + } + } + } + + private async Task OnPriorityChanged(ChangeEventArgs e) + { + if (Enum.TryParse(e.Value?.ToString(), out var newPriority)) + { + await UpdateTaskProperty(dto => dto.PriorityId = newPriority); + } + } + + private async Task OnAssigneeChanged(ChangeEventArgs e) + { + if (long.TryParse(e.Value?.ToString(), out var assigneeId)) + { + var newAssigneeId = assigneeId > 0 ? (long?)assigneeId : null; + await UpdateTaskProperty(dto => dto.AssignedTo = newAssigneeId); + assignee = newAssigneeId.HasValue ? allUsers.FirstOrDefault(u => u.Id == newAssigneeId.Value) : null; + } + } + + private async Task OnDueDateChanged(ChangeEventArgs e) + { + if (DateTime.TryParse(e.Value?.ToString(), out var newDueDate)) + { + await UpdateTaskProperty(dto => dto.DueDate = newDueDate); + } + } + + private async Task UpdateTaskProperty(Action applyChange) + { + if (task == null) return; + + var dto = new UpdateTaskDto + { + Title = task.Title, + Description = task.Description, + StatusId = task.StatusId, + PriorityId = task.PriorityId, + DueDate = task.DueDate, + AssignedTo = task.AssignedTo, + AssignedBy = task.AssignedBy + }; + + applyChange(dto); + + var client = ApiClient.CreateClient(); + try + { + var response = await client.PutAsJsonAsync($"Task/{task.Id}", dto); + if (response.IsSuccessStatusCode) + { + task.Title = dto.Title; + task.Description = dto.Description; + task.StatusId = dto.StatusId; + task.PriorityId = dto.PriorityId; + task.DueDate = dto.DueDate; + task.AssignedTo = dto.AssignedTo; + + await OnTaskUpdated.InvokeAsync(); + await JS.InvokeVoidAsync("initIcons"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Update task property error: {ex.Message}"); + } + } + + private void StartEditDescription() + { + if (task == null) return; + tempDescription = task.Description ?? ""; + isEditingDescription = true; + } + + private void CancelEditDescription() + { + isEditingDescription = false; + } + + private async Task SaveDescription() + { + isSavingDescription = true; + await UpdateTaskProperty(dto => dto.Description = tempDescription); + isEditingDescription = false; + isSavingDescription = false; + } + + private void StartEditTitle() + { + if (task == null) return; + tempTitle = task.Title; + isEditingTitle = true; + } + + private void CancelEditTitle() + { + isEditingTitle = false; + } + + private async Task SaveTitle() + { + if (string.IsNullOrWhiteSpace(tempTitle)) return; + isSavingTitle = true; + await UpdateTaskProperty(dto => dto.Title = tempTitle.Trim()); + isEditingTitle = false; + isSavingTitle = false; + } + + // ─── COMMENTS ACTIONS ────────────────────────────────────────────────────── + + private async Task PostComment() + { + if (string.IsNullOrWhiteSpace(newCommentMessage)) return; + + isSavingComment = true; + commentError = null; + try + { + var client = ApiClient.CreateClient(); + var response = await client.PostAsJsonAsync($"TaskDetails/tasks/{TaskId}/comments", new CreateCommentDto { Message = newCommentMessage }); + var res = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + + if (response.IsSuccessStatusCode && res != null && res.IsSuccess && res.Value != null) + { + comments.Add(res.Value); + newCommentMessage = ""; + await JS.InvokeVoidAsync("initIcons"); + } + else + { + commentError = res?.ErrorMessage ?? "Failed to post comment."; + } + } + catch (Exception ex) + { + commentError = ex.Message; + } + finally + { + isSavingComment = false; + } + } + + private void ConfirmDeleteComment(long commentId) + { + confirmTitle = "Delete Comment?"; + confirmMessage = "Are you sure you want to delete this comment? This action cannot be undone."; + confirmAction = () => DeleteComment(commentId); + showConfirm = true; + } + + private async Task DeleteComment(long commentId) + { + var client = ApiClient.CreateClient(); + try + { + var response = await client.DeleteAsync($"TaskDetails/comments/{commentId}"); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (response.IsSuccessStatusCode && res != null && res.IsSuccess) + { + var idx = comments.FindIndex(c => c.Id == commentId); + if (idx >= 0) comments.RemoveAt(idx); + } + } + catch (Exception ex) + { + Console.WriteLine($"Delete comment error: {ex.Message}"); + } + } + + // --- CONFIRM DIALOG LOGIC ------------------------------------------------- + + private async Task ExecuteConfirmAction() + { + showConfirm = false; + if (confirmAction != null) + { + await confirmAction(); + } + confirmAction = null; + } + + private void CancelConfirm() + { + showConfirm = false; + confirmAction = null; + } + + // --- DISPLAY UTILITIES ---------------------------------------------------- + + private string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => "Unknown" + }; + + private string GetStatusBadge(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "bg-slate-100 text-slate-800 border border-slate-200", + AppTaskStatus.InProgress => "bg-blue-50 text-blue-700 border border-blue-100", + AppTaskStatus.Done => "bg-emerald-50 text-emerald-700 border border-emerald-100", + _ => "bg-slate-50 text-slate-600" + }; + + private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "Low", + TaskPriority.Medium => "Medium", + TaskPriority.High => "High", + _ => "Unknown" + }; + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-50 text-amber-700 border border-amber-100", + TaskPriority.High => "bg-rose-50 text-rose-700 border border-rose-100", + _ => "bg-slate-50 text-slate-600" + }; +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/Tasks.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/Tasks.razor new file mode 100644 index 0000000000000000000000000000000000000000..3b0b762d258697cb94df9f5b4144edefcab048a9 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/Tasks.razor @@ -0,0 +1,1000 @@ +@page "/tasks" +@page "/tasks/all" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager Navigation + +Tasks + + + +
+ +
+
+

Tasks

+

@(isAdmin ? "View and manage all tasks across projects." : "Your assigned tasks.")

+
+
+ @if (canCreateTask) + { + + } +
+
+ + @if (isLoading) + { + + } + else + { + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + @if (PagedTasks.Any()) + { + @foreach (var entry in PagedTasks.Select((task, index) => (task, index))) + { + var task = entry.task; + + + + + + + + + + + } + } + else + { + + } + +
Task CodeTaskStatusPriorityDue DateProjectAssigneeActions
+ @GetTaskCode(task.Id) + + @task.Title + @if (!string.IsNullOrEmpty(task.Description)) + { +

@task.Description

+ } +
+ + @GetStatusLabel(task.StatusId) + + + + @GetPriorityLabel(task.PriorityId) + + @DisplayFormats.Date(task.DueDate) + @{ var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId); } + @(proj?.Name ?? $"#{task.ProjectId}") + + @{ var user = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo); } + @if (user != null) + { +
+ + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + + @user.FirstName @user.LastName +
+ } + else + { + Unassigned + } +
+
+ @if (canEditTask) + { + + } + @if (canDeleteTask) + { + + } +
+
+ + + +
+ } +
+ +@* ===== Create / Edit Modal ===== *@ +@if (showModal) +{ +
+
+
+

@(isEditing ? "Edit Task" : "New Task")

+ +
+
+
+ +
+ + @if (isTitleError) + { + + } +
+ @if (isTitleError) + { +

@titleErrorMessage

+ } +
+
+ + +
+
+
+ + + @if (isProjectError) + { +

@projectErrorMessage

+ } +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + + @if (isAssigneeDropdownOpen) + { +
+
+ +
+
+ + +
+ + @if (isLoadingProjectMembers) + { +
+ Loading project members... +
+ } + else + { +
+ + + @foreach (var user in FilteredProjectMembers) + { + var isSelected = formAssignedTo == user.Id; + + } + + @if (!FilteredProjectMembers.Any()) + { +
+ No members match the current search. +
+ } +
+ } +
+ } +
+
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+

+ + @errorMessage +

+
+ } +
+
+ + +
+
+
+} + +@* ===== Confirm Dialog ===== *@ + + +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "taskId")] + public long? TaskIdParam { get; set; } + + private bool isLoading = true; + private bool isAdmin = false; + private bool isManager = false; + private bool canCreateTask = false; + private bool canEditTask = false; + private bool canDeleteTask = false; + private bool canUpdateTask = false; + private long currentUserId = 0; + private bool showModal = false; + private bool isEditing = false; + private bool isSaving = false; + private bool isAssigneeDropdownOpen = false; + private string? errorMessage; + + // Per-field validation + private bool isTitleError = false; + private string titleErrorMessage = ""; + private bool isProjectError = false; + private string projectErrorMessage = ""; + + // Confirm dialog state + private bool showConfirmDialog = false; + private string confirmTitle = ""; + private string confirmMessage = ""; + private string confirmButtonText = ""; + private string confirmIcon = ""; + private string confirmIconBg = ""; + private string confirmIconText = ""; + private string confirmButtonClass = ""; + private Func? confirmAction; + private List taskList = new(); + private List projects = new(); + private List allUsers = new(); + private List projectMembers = new(); + + private string? successMessage; + + // Search and Filters state + private string searchInput = ""; + private long filterProjectIdInput = 0; + private AppTaskStatus? filterStatusIdInput = null; + private TaskPriority? filterPriorityIdInput = null; + + // Pagination state + private int currentPage = 1; + private int pageSize = 10; + private int totalCount = 0; + private int totalPages = 0; + private int TotalPages => totalPages; + private int TotalCount => totalCount; + private IEnumerable PagedTasks => taskList; + + // Form fields + private long editingId; + private string formTitle = ""; + private string? formDescription; + private long formProjectId; + private AppTaskStatus formStatusId = AppTaskStatus.Todo; + private TaskPriority formPriorityId = TaskPriority.Medium; + private DateTime formDueDate = DateTime.Today.AddDays(7); + private long? formAssignedTo; + private string assigneeSearchInput = ""; + private bool isLoadingProjectMembers; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + isAdmin = user.IsInRole("Admin"); + isManager = user.IsInRole("Manager"); + var idStr = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (idStr != null) long.TryParse(idStr, out currentUserId); + await LoadTaskAccessAsync(user); + + var client = ApiClient.CreateClient(); + try + { + var tasksTask = LoadTasksPageAsync(client); + var projectsTask = client.GetFromJsonAsync>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync>("User", Serialization.CaseInsensitive); + await System.Threading.Tasks.Task.WhenAll(tasksTask, projectsTask, usersTask); + + taskList = tasksTask.Result?.Items ?? new(); + totalCount = tasksTask.Result?.TotalCount ?? 0; + totalPages = tasksTask.Result?.TotalPages ?? 0; + currentPage = tasksTask.Result?.Page ?? currentPage; + pageSize = tasksTask.Result?.PageSize ?? pageSize; + projects = projectsTask.Result ?? new(); + allUsers = usersTask.Result ?? new(); + } + catch (Exception ex) + { + Console.WriteLine($"Load error: {ex.Message}"); + } + finally + { + if (TaskIdParam.HasValue && TaskIdParam > 0) + { + await FocusTaskAsync(TaskIdParam.Value); + } + } + + isLoading = false; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task?> LoadTasksPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + var response = await client.GetFromJsonAsync>(BuildTasksUrl(), Serialization.CaseInsensitive); + if (response != null) + { + return response; + } + + return null; + } + + // Pagination handlers + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadTasks(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadTasks(); + } + + private async Task ApplySearch() + { + currentPage = 1; + await LoadTasks(); + } + + private async Task ResetSearch() + { + searchInput = ""; + filterProjectIdInput = 0; + filterStatusIdInput = null; + filterPriorityIdInput = null; + currentPage = 1; + await LoadTasks(); + } + + private async Task LoadTasks() + { + var client = ApiClient.CreateClient(); + var result = await LoadTasksPageAsync(client); + taskList = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + } + + private string BuildTasksUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + if (filterProjectIdInput > 0) + { + query.Add($"projectId={filterProjectIdInput}"); + } + + if (filterStatusIdInput.HasValue) + { + query.Add($"statusId={(int)filterStatusIdInput.Value}"); + } + + if (filterPriorityIdInput.HasValue) + { + query.Add($"priorityId={(int)filterPriorityIdInput.Value}"); + } + + if (!(isAdmin || isManager) && currentUserId > 0) + { + query.Add("assignedOnly=true"); + } + + return $"Task?{string.Join("&", query)}"; + } + + private async Task FocusTaskAsync(long taskId) + { + try + { + var client = ApiClient.CreateClient(); + var task = await client.GetFromJsonAsync($"Task/{taskId}", Serialization.CaseInsensitive); + if (task == null) + { + return; + } + + searchInput = task.Title; + currentPage = 1; + await LoadTasks(); + } + catch (Exception ex) + { + Console.WriteLine($"Focus task error: {ex.Message}"); + } + } + + private async Task LoadTaskAccessAsync(System.Security.Claims.ClaimsPrincipal user) + { + canCreateTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Create"); + canEditTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Update"); + canDeleteTask = isAdmin || await MenuAuthorization.HasAccessCodeAsync(user, "Tasks_Delete"); + canUpdateTask = canEditTask; + } + + private IEnumerable FilteredProjectMembers + { + get + { + var result = projectMembers.Where(u => u.IsActive); + + if (!string.IsNullOrWhiteSpace(assigneeSearchInput)) + { + var search = assigneeSearchInput.Trim(); + result = result.Where(u => + $"{u.FirstName} {u.LastName}".Contains(search, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(u.Username) && u.Username.Contains(search, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(u.Email) && u.Email.Contains(search, StringComparison.OrdinalIgnoreCase))); + } + + return result + .OrderBy(u => u.FirstName) + .ThenBy(u => u.LastName) + .ToList(); + } + } + + private string SelectedAssigneeLabel + { + get + { + if (!formAssignedTo.HasValue) + { + return "Unassigned"; + } + + var selectedUser = projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value); + return selectedUser == null + ? "Selected member" + : $"{selectedUser.FirstName} {selectedUser.LastName}".Trim(); + } + } + + private async Task OnFormProjectChanged(ChangeEventArgs e) + { + if (long.TryParse(e.Value?.ToString(), out var projectId)) + { + formProjectId = projectId; + } + else + { + formProjectId = 0; + } + + formAssignedTo = null; + assigneeSearchInput = ""; + await LoadProjectMembersAsync(formProjectId); + } + + private void SetFormAssignee(long? userId) + { + formAssignedTo = userId; + errorMessage = null; + isAssigneeDropdownOpen = false; + } + + private void OpenAssigneeDropdown() + { + if (formProjectId <= 0) + { + return; + } + + isAssigneeDropdownOpen = true; + } + + private void ToggleAssigneeDropdown() + { + if (isAssigneeDropdownOpen) + { + CloseAssigneeDropdown(); + return; + } + + OpenAssigneeDropdown(); + } + + private void EnsureAssigneeDropdownOpen() + { + if (formProjectId <= 0) + { + return; + } + + isAssigneeDropdownOpen = true; + } + + private void CloseAssigneeDropdown() + { + isAssigneeDropdownOpen = false; + } + + private async Task LoadProjectMembersAsync(long projectId) + { + projectMembers.Clear(); + isLoadingProjectMembers = true; + + try + { + if (projectId <= 0) + { + return; + } + + var client = ApiClient.CreateClient(); + var membersResult = await client.GetFromJsonAsync>>($"Project/{projectId}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + projectMembers = membersResult.Value + .Where(user => user.IsActive) + .OrderBy(user => user.FirstName) + .ThenBy(user => user.LastName) + .ToList(); + + if (formAssignedTo.HasValue && !projectMembers.Any(user => user.Id == formAssignedTo.Value)) + { + formAssignedTo = null; + } + } + else + { + formAssignedTo = null; + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to load project members for task form: {ex.Message}"); + formAssignedTo = null; + } + finally + { + isLoadingProjectMembers = false; + await InvokeAsync(StateHasChanged); + await JS.InvokeVoidAsync("initIcons"); + } + } + + private async Task OpenCreateModal() + { + isEditing = false; + editingId = 0; + formTitle = ""; + formDescription = null; + formProjectId = projects.FirstOrDefault()?.Id ?? 0; + formStatusId = AppTaskStatus.Todo; + formPriorityId = TaskPriority.Medium; + formDueDate = DateTime.Today.AddDays(7); + formAssignedTo = null; + assigneeSearchInput = ""; + errorMessage = null; + isTitleError = false; titleErrorMessage = ""; + isProjectError = false; projectErrorMessage = ""; + showModal = true; + + await LoadProjectMembersAsync(formProjectId); + } + + private async Task OpenEditModal(TaskDto task) + { + isEditing = true; + editingId = task.Id; + formTitle = task.Title; + formDescription = task.Description; + formProjectId = task.ProjectId; + formStatusId = task.StatusId; + formPriorityId = task.PriorityId; + formDueDate = task.DueDate; + formAssignedTo = task.AssignedTo; + assigneeSearchInput = ""; + errorMessage = null; + isTitleError = false; titleErrorMessage = ""; + isProjectError = false; projectErrorMessage = ""; + showModal = true; + + await LoadProjectMembersAsync(formProjectId); + } + + private void CloseModal() + { + showModal = false; + errorMessage = null; + isTitleError = false; titleErrorMessage = ""; + isProjectError = false; projectErrorMessage = ""; + assigneeSearchInput = ""; + isAssigneeDropdownOpen = false; + projectMembers.Clear(); + isLoadingProjectMembers = false; + } + + private void RequestSaveConfirmation() + { + isTitleError = false; titleErrorMessage = ""; + isProjectError = false; projectErrorMessage = ""; + bool hasError = false; + + if (string.IsNullOrWhiteSpace(formTitle)) + { + isTitleError = true; + titleErrorMessage = "Task title is required."; + hasError = true; + } + if (formProjectId == 0) + { + isProjectError = true; + projectErrorMessage = "Please select a project."; + hasError = true; + } + if (hasError) return; + + if (isEditing) + { + confirmTitle = "Update Task?"; + confirmMessage = "Are you sure you want to update this task?"; + confirmButtonText = "Update"; + confirmIcon = "pencil"; + confirmIconBg = "bg-amber-50"; + confirmIconText = "text-amber-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + else + { + confirmTitle = "Create Task?"; + confirmMessage = "Are you sure you want to create this task?"; + confirmButtonText = "Create"; + confirmIcon = "plus-circle"; + confirmIconBg = "bg-emerald-50"; + confirmIconText = "text-emerald-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + confirmAction = SaveTask; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = "Delete Task?"; + confirmMessage = "Are you sure you want to delete this task? This action cannot be undone."; + confirmButtonText = "Delete"; + confirmIcon = "trash-2"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + confirmAction = () => DeleteTask(id); + showConfirmDialog = true; + } + + private async Task HandleConfirmAction() + { + showConfirmDialog = false; + if (confirmAction != null) + await confirmAction(); + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task SaveTask() + { + if (string.IsNullOrWhiteSpace(formTitle)) + { + errorMessage = ResultMessages.TaskTitleRequired; + return; + } + if (formProjectId == 0) + { + errorMessage = ResultMessages.SelectProjectRequired; + return; + } + + isSaving = true; + errorMessage = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateTaskDto + { + Title = formTitle, + Description = formDescription, + StatusId = formStatusId, + PriorityId = formPriorityId, + DueDate = formDueDate, + AssignedTo = formAssignedTo + }; + var response = await client.PutAsJsonAsync($"Task/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateTask; + return; + } + } + else + { + var dto = new CreateTaskDto + { + Title = formTitle, + Description = formDescription, + ProjectId = formProjectId, + StatusId = formStatusId, + PriorityId = formPriorityId, + DueDate = formDueDate, + AssignedTo = formAssignedTo + }; + var response = await client.PostAsJsonAsync("Task", dto); + var res = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateTask; + return; + } + } + + CloseModal(); + await LoadTasks(); + successMessage = isEditing ? "Task updated successfully!" : "Task created successfully!"; + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isSaving = false; + } + } + + private async Task DeleteTask(long id) + { + var client = ApiClient.CreateClient(); + try + { + var response = await client.DeleteAsync($"Task/{id}"); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? "Failed to delete task."); + return; + } + await LoadTasks(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = "Task deleted successfully!"; + } + catch (Exception ex) + { + Console.WriteLine($"Delete error: {ex.Message}"); + } + } + + private string GetStatusBadge(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "bg-slate-100 text-slate-700", + AppTaskStatus.InProgress => "bg-blue-100 text-blue-700", + AppTaskStatus.Done => "bg-emerald-100 text-emerald-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => "Unknown" + }; + + private static string GetTaskCode(long taskId) => $"TSK-{taskId:D4}"; + + private string GetPriorityBadge(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "bg-slate-100 text-slate-600", + TaskPriority.Medium => "bg-amber-100 text-amber-700", + TaskPriority.High => "bg-red-100 text-red-700", + _ => "bg-slate-100 text-slate-600" + }; + + private string GetPriorityLabel(TaskPriority priorityId) => priorityId switch + { + TaskPriority.Low => "Low", + TaskPriority.Medium => "Medium", + TaskPriority.High => "High", + _ => "-" + }; + +} diff --git a/TaskTrackingSystem.WebApp/Components/Pages/Features/Users/Users.razor b/TaskTrackingSystem.WebApp/Components/Pages/Features/Users/Users.razor new file mode 100644 index 0000000000000000000000000000000000000000..35dc280fe8625535925976454a4126d85840b65b --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Users/Users.razor @@ -0,0 +1,703 @@ +@page "/users" +@using Microsoft.AspNetCore.Authorization +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@inject ApiClientService ApiClient +@inject IJSRuntime JS +@using Microsoft.AspNetCore.Components.Authorization +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +@AppLocalization.PageTitle("users", "Users") + + + +
+ +
+
+

@AppLocalization.PageTitle("users", "Users")

+

@AppLocalization.PageDescription("users", "Manage people, status, and account details.")

+
+ @if (canCreateUser) + { + + } +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + @if (isLoading) + { + + } + else + { + +
+ + + + + + + + + + + + + + @if (PagedUsers.Any()) + { + @foreach (var entry in PagedUsers.Select((user, index) => (user, index))) + { + var user = entry.user; + + + + + + + + + + } + } + else + { + + } + +
No.@AppLocalization.Text("common.user", "User")EmailPhone@AppLocalization.Text("common.role", "Role")@AppLocalization.Text("common.status", "Status")@AppLocalization.Text("common.actions", "Actions")
@((currentPage - 1) * pageSize + entry.index + 1) +
+ + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + +
+

@user.FirstName @user.LastName

+

@@@user.Username

+
+
+
@user.Email@(user.Phone ?? "-") + @{ var role = roles.FirstOrDefault(r => r.Id == user.RoleId); } + + @(role?.Name ?? $"Role #{user.RoleId}") + + + + @(user.IsActive ? AppLocalization.Text("status.active", "Active") : AppLocalization.Text("status.inactive", "Inactive")) + + +
+ @if (canEditUser) + { + + } + @if (canDeleteUser) + { + + } +
+
+ + + +
+ } +
+ +@* ===== Create / Edit User Modal ===== *@ +@if (showModal) +{ +
+
+
+

@(isEditing ? AppLocalization.Text("user.editUser", "Edit User") : AppLocalization.Text("common.newUser", "New User"))

+ +
+
+
+
+ +
+ + @if (isFirstNameError) { } +
+ @if (isFirstNameError) {

@firstNameErrorMsg

} +
+
+ +
+ + @if (isLastNameError) { } +
+ @if (isLastNameError) {

@lastNameErrorMsg

} +
+
+
+ +
+ + @if (isUsernameError) { } +
+ @if (isUsernameError) {

@usernameErrorMsg

} +
+
+ +
+ + @if (isEmailError) { } +
+ @if (isEmailError) {

@emailErrorMsg

} +
+ @if (!isEditing) + { +
+ + + + @if (isPasswordError) {

@passwordErrorMsg

} +
+ } +
+
+ + +
+
+ + +
+
+ @if (isEditing) + { +
+ + +
+ } + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+

+ + @errorMessage +

+
+ } +
+
+ + +
+
+
+} + +@* ===== Confirm Dialog ===== *@ + + +@code { + private bool isLoading = true; + private bool showModal = false; + private bool isEditing = false; + private bool isSaving = false; + private bool canCreateUser = false; + private bool canEditUser = false; + private bool canDeleteUser = false; + private string? errorMessage; + + // Per-field validation errors + private bool isFirstNameError = false; private string firstNameErrorMsg = ""; + private bool isLastNameError = false; private string lastNameErrorMsg = ""; + private bool isUsernameError = false; private string usernameErrorMsg = ""; + private bool isEmailError = false; private string emailErrorMsg = ""; + private bool isPasswordError = false; private string passwordErrorMsg = ""; + + // Confirm dialog state + private bool showConfirmDialog = false; + private string confirmTitle = ""; + private string confirmMessage = ""; + private string confirmButtonText = ""; + private string confirmIcon = ""; + private string confirmIconBg = ""; + private string confirmIconText = ""; + private string confirmButtonClass = ""; + private Func? confirmAction; + private List users = new(); + private List roles = new(); + + private string? successMessage; + private bool showPassword = false; + + // Search / filter + private string searchInput = ""; + private long filterRoleIdInput = 0; + private string filterStatusInput = "all"; + + // Pagination state + private int currentPage = 1; + private int pageSize = 10; + private int totalCount = 0; + private int totalPages = 0; + private int TotalPages => totalPages; + private int TotalCount => totalCount; + private IEnumerable PagedUsers => users; + + // Form fields + private long editingId; + private string formUsername = ""; + private string formFirstName = ""; + private string formLastName = ""; + private string formEmail = ""; + private string formPassword = ""; + private string? formPhone; + private long formRoleId; + private bool formIsActive = true; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + canCreateUser = await MenuAuthorization.HasAccessCodeAsync(user, "Users_Create"); + canEditUser = await MenuAuthorization.HasAccessCodeAsync(user, "Users_Update"); + canDeleteUser = await MenuAuthorization.HasAccessCodeAsync(user, "Users_Delete"); + + var client = ApiClient.CreateClient(); + try + { + var usersTask = LoadUsersPageAsync(client); + var rolesTask = client.GetFromJsonAsync>("Role", Serialization.CaseInsensitive); + await System.Threading.Tasks.Task.WhenAll(usersTask, rolesTask); + + users = usersTask.Result?.Items ?? new(); + totalCount = usersTask.Result?.TotalCount ?? 0; + totalPages = usersTask.Result?.TotalPages ?? 0; + currentPage = usersTask.Result?.Page ?? currentPage; + pageSize = usersTask.Result?.PageSize ?? pageSize; + roles = rolesTask.Result ?? new(); + } + catch (Exception ex) + { + Console.WriteLine($"Load error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task?> LoadUsersPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + return await client.GetFromJsonAsync>(BuildUsersUrl(), Serialization.CaseInsensitive); + } + + // Pagination handlers + private async Task HandlePageChanged(int page) + { + currentPage = page; + await LoadUsers(); + } + + private async Task HandlePageSizeChanged(int size) + { + pageSize = size; + currentPage = 1; + await LoadUsers(); + } + + private async Task ApplySearch() + { + currentPage = 1; + await LoadUsers(); + } + + private async Task ResetSearch() + { + searchInput = ""; + filterRoleIdInput = 0; + filterStatusInput = "all"; + currentPage = 1; + await LoadUsers(); + } + + private async Task LoadUsers() + { + var client = ApiClient.CreateClient(); + var result = await LoadUsersPageAsync(client); + users = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + } + + private string BuildUsersUrl() + { + var query = new List + { + $"page={currentPage}", + $"limit={pageSize}" + }; + + if (!string.IsNullOrWhiteSpace(searchInput)) + { + query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}"); + } + + if (filterRoleIdInput > 0) + { + query.Add($"roleId={filterRoleIdInput}"); + } + + if (!string.Equals(filterStatusInput, "all", StringComparison.OrdinalIgnoreCase)) + { + query.Add($"status={Uri.EscapeDataString(filterStatusInput)}"); + } + + return $"User?{string.Join("&", query)}"; + } + + private void OpenCreateModal() + { + isEditing = false; + editingId = 0; + formUsername = ""; + formFirstName = ""; + formLastName = ""; + formEmail = ""; + formPassword = ""; + formPhone = null; + formRoleId = roles.FirstOrDefault()?.Id ?? 0; + formIsActive = true; + errorMessage = null; + isFirstNameError = false; firstNameErrorMsg = ""; + isLastNameError = false; lastNameErrorMsg = ""; + isUsernameError = false; usernameErrorMsg = ""; + isEmailError = false; emailErrorMsg = ""; + isPasswordError = false; passwordErrorMsg = ""; + showModal = true; + } + + private void OpenEditModal(UserDto user) + { + isEditing = true; + editingId = user.Id; + formUsername = user.Username; + formFirstName = user.FirstName; + formLastName = user.LastName; + formEmail = user.Email; + formPhone = user.Phone; + formRoleId = user.RoleId; + formIsActive = user.IsActive; + errorMessage = null; + isFirstNameError = false; firstNameErrorMsg = ""; + isLastNameError = false; lastNameErrorMsg = ""; + isUsernameError = false; usernameErrorMsg = ""; + isEmailError = false; emailErrorMsg = ""; + isPasswordError = false; passwordErrorMsg = ""; + showModal = true; + } + + private void CloseModal() + { + showModal = false; + errorMessage = null; + isFirstNameError = false; isLastNameError = false; isUsernameError = false; + isEmailError = false; isPasswordError = false; + } + + private void RequestSaveConfirmation() + { + isFirstNameError = false; firstNameErrorMsg = ""; + isLastNameError = false; lastNameErrorMsg = ""; + isUsernameError = false; usernameErrorMsg = ""; + isEmailError = false; emailErrorMsg = ""; + isPasswordError = false; passwordErrorMsg = ""; + bool hasError = false; + + if (string.IsNullOrWhiteSpace(formFirstName)) { isFirstNameError = true; firstNameErrorMsg = AppLocalization.Text("validation.firstNameRequired", "First name is required."); hasError = true; } + if (string.IsNullOrWhiteSpace(formLastName)) { isLastNameError = true; lastNameErrorMsg = AppLocalization.Text("validation.lastNameRequired", "Last name is required."); hasError = true; } + + if (string.IsNullOrWhiteSpace(formUsername)) { isUsernameError = true; usernameErrorMsg = AppLocalization.Text("validation.usernameRequired", "Username is required."); hasError = true; } + else if (formUsername.Length < 3) { isUsernameError = true; usernameErrorMsg = ResultMessages.UsernameMinLength; hasError = true; } + else if (!System.Text.RegularExpressions.Regex.IsMatch(formUsername, @"^[a-zA-Z0-9._]+$")) { isUsernameError = true; usernameErrorMsg = ResultMessages.UsernameInvalidCharacters; hasError = true; } + + if (string.IsNullOrWhiteSpace(formEmail)) { isEmailError = true; emailErrorMsg = AppLocalization.Text("validation.emailRequired", "Email is required."); hasError = true; } + + if (!isEditing) + { + if (string.IsNullOrWhiteSpace(formPassword)) + { + isPasswordError = true; + passwordErrorMsg = AppLocalization.Text("validation.passwordRequired", "Password is required."); + hasError = true; + } + else if (formPassword.Length < 8) + { + isPasswordError = true; + passwordErrorMsg = ResultMessages.PasswordMinLengthRule; + hasError = true; + } + else if (!System.Text.RegularExpressions.Regex.IsMatch(formPassword, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$")) + { + isPasswordError = true; + passwordErrorMsg = ResultMessages.PasswordComplexityRule; + hasError = true; + } + } + if (hasError) return; + + if (isEditing) + { + confirmTitle = AppLocalization.Text("user.updateUser", "Update User?"); + confirmMessage = AppLocalization.Text("user.updateUserConfirm", "Are you sure you want to update this user?"); + confirmButtonText = AppLocalization.Text("action.update", "Update"); + confirmIcon = "pencil"; + confirmIconBg = "bg-amber-50"; + confirmIconText = "text-amber-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + else + { + confirmTitle = AppLocalization.Text("user.createUser", "Create User?"); + confirmMessage = AppLocalization.Text("user.createUserConfirm", "Are you sure you want to create this user?"); + confirmButtonText = AppLocalization.Text("action.create", "Create"); + confirmIcon = "plus-circle"; + confirmIconBg = "bg-emerald-50"; + confirmIconText = "text-emerald-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + } + confirmAction = SaveUser; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = AppLocalization.Text("user.deleteUser", "Delete User?"); + confirmMessage = AppLocalization.Text("user.deleteUserConfirm", "Are you sure you want to delete this user? This action cannot be undone."); + confirmButtonText = AppLocalization.Text("action.delete", "Delete"); + confirmIcon = "trash-2"; + confirmIconBg = "bg-rose-50"; + confirmIconText = "text-rose-500"; + confirmButtonClass = "bg-violet-600 hover:bg-violet-700"; + confirmAction = () => DeleteUser(id); + showConfirmDialog = true; + } + + private async Task HandleConfirmAction() + { + showConfirmDialog = false; + if (confirmAction != null) + await confirmAction(); + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task SaveUser() + { + if (string.IsNullOrWhiteSpace(formUsername) || string.IsNullOrWhiteSpace(formFirstName) || string.IsNullOrWhiteSpace(formLastName) || string.IsNullOrWhiteSpace(formEmail)) + { + errorMessage = ResultMessages.FillAllFields; + return; + } + + isSaving = true; + errorMessage = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateUserDto + { + Username = formUsername, + FirstName = formFirstName, + LastName = formLastName, + Phone = formPhone, + RoleId = formRoleId, + IsActive = formIsActive + }; + var response = await client.PutAsJsonAsync($"User/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateUser; + return; + } + } + else + { + if (string.IsNullOrWhiteSpace(formPassword)) + { + errorMessage = AppLocalization.Text("validation.passwordRequired", "Password is required."); + isSaving = false; + return; + } + if (formPassword.Length < 8) + { + errorMessage = ResultMessages.PasswordMinLengthRule; + isSaving = false; + return; + } + if (!System.Text.RegularExpressions.Regex.IsMatch(formPassword, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$")) + { + errorMessage = ResultMessages.PasswordComplexityRule; + isSaving = false; + return; + } + var dto = new CreateUserDto + { + Username = formUsername, + FirstName = formFirstName, + LastName = formLastName, + Email = formEmail, + Password = formPassword, + Phone = formPhone, + RoleId = formRoleId + }; + var response = await client.PostAsJsonAsync("User", dto); + var res = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateUser; + return; + } + } + + CloseModal(); + await LoadUsers(); + successMessage = isEditing ? AppLocalization.Text("result.userUpdated", "User updated successfully!") : AppLocalization.Text("result.userCreated", "User created successfully!"); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + isSaving = false; + } + } + + private async Task DeleteUser(long id) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var currentUserIdStr = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (currentUserIdStr != null && long.TryParse(currentUserIdStr, out var currentUserId) && currentUserId == id) + { + await JS.InvokeVoidAsync("alert", AppLocalization.Text("user.cannotDeleteSelf", "You cannot delete your own account.")); + return; + } + + var client = ApiClient.CreateClient(); + try + { + var response = await client.DeleteAsync($"User/{id}"); + var res = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? AppLocalization.Text("result.failedToDeleteUser", "Failed to delete user.")); + return; + } + await LoadUsers(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = AppLocalization.Text("result.userDeleted", "User deleted successfully!"); + } + catch (Exception ex) + { + Console.WriteLine($"Delete error: {ex.Message}"); + } + } + + private async Task TogglePassword() + { + showPassword = !showPassword; + await JS.InvokeVoidAsync("initIcons"); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/ConfirmDialog.razor b/TaskTrackingSystem.WebApp/Components/Partial/ConfirmDialog.razor new file mode 100644 index 0000000000000000000000000000000000000000..b2c81f28729be35fb0bc949c803df8e7c6edd1c4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/ConfirmDialog.razor @@ -0,0 +1,64 @@ +@if (IsVisible) +{ +
+
+
+
+ +
+

@Title

+

@Message

+
+
+ @if (!HideCancel) + { + + } + +
+
+
+} + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public string Title { get; set; } = AppLocalization.Text("common.areYouSure", "Are you sure?"); + [Parameter] public string Message { get; set; } = AppLocalization.Text("common.cannotUndo", "This action cannot be undone."); + [Parameter] public string ConfirmText { get; set; } = AppLocalization.Text("common.confirmAction", "Confirm"); + [Parameter] public string Icon { get; set; } = "alert-triangle"; + [Parameter] public string IconBgClass { get; set; } = ""; + [Parameter] public string IconTextClass { get; set; } = ""; + [Parameter] public string ButtonClass { get; set; } = ""; + [Parameter] public bool HideCancel { get; set; } + [Parameter] public EventCallback OnConfirm { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string ConfirmButtonStyle => string.IsNullOrWhiteSpace(ButtonClass) + ? $"background-color: {GetButtonBg()};" + : string.Empty; + + private string GetIconBg() + { + if (Icon.Contains("trash") || Icon.Contains("alert")) + return "var(--status-danger-bg)"; + return "var(--status-success-bg)"; + } + + private string GetIconColor() + { + if (Icon.Contains("trash") || Icon.Contains("alert")) + return "var(--status-danger-text)"; + return "var(--brand-primary)"; + } + + private string GetButtonBg() + { + if (Icon.Contains("trash") || Icon.Contains("alert")) + return "var(--status-danger-text)"; + return "var(--brand-primary)"; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/EmptyState.razor b/TaskTrackingSystem.WebApp/Components/Partial/EmptyState.razor new file mode 100644 index 0000000000000000000000000000000000000000..bc1f25199f84f7271ff278ec60d359084d8dc208 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/EmptyState.razor @@ -0,0 +1,25 @@ +@* Partial/EmptyState.razor - Reusable empty state for table bodies *@ + + + +
+ +
+

@Title

+

@Subtitle

+ + + +@code { + /// Number of columns to span. + [Parameter] public int ColSpan { get; set; } = 1; + + /// Lucide icon name to display. + [Parameter] public string Icon { get; set; } = "inbox"; + + /// Primary empty-state message. + [Parameter] public string Title { get; set; } = AppLocalization.Text("common.noDataYet", "No data yet"); + + /// Secondary hint text. + [Parameter] public string Subtitle { get; set; } = AppLocalization.Text("common.createFirstEntry", "Create your first entry to get started."); +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/FirebaseTokenSync.razor b/TaskTrackingSystem.WebApp/Components/Partial/FirebaseTokenSync.razor new file mode 100644 index 0000000000000000000000000000000000000000..4ec3809c6d2cf1e3d4b23f028ee6a1e80f99a529 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/FirebaseTokenSync.razor @@ -0,0 +1,53 @@ +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@inject IJSRuntime JS +@inject ApiClientService ApiClient +@inject AuthenticationStateProvider AuthStateProvider + +@code { + private bool _hasRun; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_hasRun || !firstRender) + { + return; + } + + _hasRun = true; + + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated != true) + { + return; + } + + try + { + var token = await JS.InvokeAsync("ttsFirebase.getRegistrationToken"); + if (string.IsNullOrWhiteSpace(token)) + { + Console.WriteLine("[DEBUG FirebaseTokenSync] FCM registration token is empty or null."); + return; + } + + var client = ApiClient.CreateClient(authState.User); + var response = await client.PostAsJsonAsync("UserDevices/register", new RegisterDeviceTokenDto + { + FcmToken = token + }); + if (response.IsSuccessStatusCode) + { + Console.WriteLine("[DEBUG FirebaseTokenSync] Successfully registered FCM token."); + } + else + { + var error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"[DEBUG FirebaseTokenSync] Failed to register token with API: {response.StatusCode} - {error}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[DEBUG FirebaseTokenSync] Exception occurred: {ex.Message}\n{ex.StackTrace}"); + } + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/LanguageToggle.razor b/TaskTrackingSystem.WebApp/Components/Partial/LanguageToggle.razor new file mode 100644 index 0000000000000000000000000000000000000000..198fb7e07d66824bad07e1b98faa827fe711a575 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/LanguageToggle.razor @@ -0,0 +1,46 @@ +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@implements IDisposable +@inject TaskTrackingSystem.WebApp.Localization.UiLanguageService Language + + + +@code { + private string ButtonTitle => Language.CurrentLanguage == TaskTrackingSystem.WebApp.Localization.AppLanguage.Burmese + ? Language.Texts.LanguageTitle + ": " + Language.Texts.LanguageEnglish + : Language.Texts.LanguageTitle + ": " + Language.Texts.LanguageBurmese; + + private string ButtonLabel => Language.CurrentLanguage == TaskTrackingSystem.WebApp.Localization.AppLanguage.Burmese + ? "MY" + : "EN"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + Language.Changed += OnLanguageChanged; + await Language.InitializeAsync(); + StateHasChanged(); + } + } + + private async Task ToggleLanguage() + { + await Language.ToggleAsync(); + } + + private void OnLanguageChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + Language.Changed -= OnLanguageChanged; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/LoadingSpinner.razor b/TaskTrackingSystem.WebApp/Components/Partial/LoadingSpinner.razor new file mode 100644 index 0000000000000000000000000000000000000000..ebf4a752ef454e3b290dacaa42372a3a6f6a7811 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/LoadingSpinner.razor @@ -0,0 +1,16 @@ +@* Partial/LoadingSpinner.razor - Reusable loading state component *@ + +
+
+ + + + + @Message +
+
+ +@code { + /// The text to display next to the spinner. + [Parameter] public string Message { get; set; } = "Loading..."; +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor b/TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor new file mode 100644 index 0000000000000000000000000000000000000000..5932cede28c633a68e54ed73f48aa8d7f098dc71 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor @@ -0,0 +1,283 @@ +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@implements IAsyncDisposable +@inject ApiClientService ApiClient +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation +@inject IJSRuntime JS + +
+ + + @if (IsOpen) + { +
+
+
+

@AppLocalization.Text("notification.notifications", "Notifications")

+

@UnreadCount @AppLocalization.Text("notification.unread", "unread")

+
+
+ @if (Notifications.Any(item => item.IsRead)) + { + + } + + +
+
+ +
+ @if (IsLoading) + { +
@AppLocalization.Text("notification.loading", "Loading notifications...")
+ } + else if (Notifications.Count == 0) + { +
+
+ +
+

@AppLocalization.Text("notification.noneYet", "No notifications yet")

+

@AppLocalization.Text("notification.emptyState", "Your task alerts will show up here.")

+
+ } + else + { + @foreach (var item in Notifications) + { + + } + } +
+
+ } +
+ + + +@code { + private bool IsOpen; + private bool IsLoading; + private bool showCleanupConfirmDialog; + private int UnreadCount; + private List Notifications = new(); + + protected override async Task OnInitializedAsync() + { + await RefreshAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task ToggleOpenAsync() + { + IsOpen = !IsOpen; + if (IsOpen) + { + await RefreshAsync(); + } + } + + private void Close() + { + IsOpen = false; + } + + private async Task RefreshAsync() + { + IsLoading = true; + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated != true) + { + Notifications.Clear(); + UnreadCount = 0; + return; + } + + var client = ApiClient.CreateClient(authState.User); + var unreadTask = client.GetFromJsonAsync("Notification/unread-count"); + var itemsTask = client.GetFromJsonAsync>("Notification/mine?take=10"); + + await Task.WhenAll(unreadTask, itemsTask); + UnreadCount = unreadTask.Result; + Notifications = (itemsTask.Result ?? new List()) + .Select(item => + { + item.TargetUrl = BuildTargetUrl(item); + return item; + }) + .ToList(); + } + catch (Exception ex) + { + Console.WriteLine($"[DEBUG NotificationBell] Failed to refresh: {ex.Message}"); + } + finally + { + IsLoading = false; + await InvokeAsync(StateHasChanged); + } + } + + private void RequestCleanupConfirmation() + { + showCleanupConfirmDialog = true; + } + + private void CloseCleanupConfirmDialog() + { + showCleanupConfirmDialog = false; + } + + private async Task ConfirmCleanupAsync() + { + showCleanupConfirmDialog = false; + await ClearReadNotificationsAsync(); + } + + private async Task ClearReadNotificationsAsync() + { + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated != true) + { + return; + } + + var client = ApiClient.CreateClient(authState.User); + var response = await client.DeleteAsync("Notification/read"); + if (response.IsSuccessStatusCode) + { + await RefreshAsync(); + } + } + catch (Exception ex) + { + Console.WriteLine($"[DEBUG NotificationBell] Clear read notifications failed: {ex.Message}"); + } + } + + private async Task MarkReadAsync(NotificationDto item) + { + if (item.IsRead) + { + Navigation.NavigateTo(GetTargetUrl(item)); + return; + } + + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated != true) + { + return; + } + + var client = ApiClient.CreateClient(authState.User); + var response = await client.PostAsync($"Notification/{item.Id}/read", null); + if (response.IsSuccessStatusCode) + { + item.IsRead = true; + if (UnreadCount > 0) + { + UnreadCount--; + } + await InvokeAsync(StateHasChanged); + Navigation.NavigateTo(GetTargetUrl(item)); + } + } + catch (Exception ex) + { + Console.WriteLine($"[DEBUG NotificationBell] MarkRead failed: {ex.Message}"); + } + } + + private async Task OpenNotificationAsync(NotificationDto item) + { + await MarkReadAsync(item); + IsOpen = false; + } + + private static string GetTargetUrl(NotificationDto item) + { + if (!string.IsNullOrWhiteSpace(item.TargetUrl)) + { + return item.TargetUrl; + } + + return BuildTargetUrl(item); + } + + private static string BuildTargetUrl(NotificationDto item) + { + return NotificationNavigation.BuildTargetUrl(item.SourceType, item.SourceId, item.NotificationType); + } + + private static string FormatSender(string? senderName) + { + return string.IsNullOrWhiteSpace(senderName) ? "System" : senderName; + } + + private static string FormatTime(DateTime? createdAt) + { + if (!createdAt.HasValue) + { + return string.Empty; + } + + var value = createdAt.Value.ToLocalTime(); + return value.ToString("dd MMM, HH:mm"); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/Pagination.razor b/TaskTrackingSystem.WebApp/Components/Partial/Pagination.razor new file mode 100644 index 0000000000000000000000000000000000000000..5dfb34d668bc12a19e9d4383a0e4e44e67b7bf42 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/Pagination.razor @@ -0,0 +1,115 @@ +@* Partial/Pagination.razor - Reusable pagination component *@ + +
+
+ @if (TotalCount == 0) + { + No results + } + else + { + var from = (CurrentPage - 1) * PageSize + 1; + var to = Math.Min(CurrentPage * PageSize, TotalCount); + Showing @from-@to of @TotalCount results + } +
+ +
+
+ Rows per page + +
+ +
+ + + + @foreach (var p in GetVisiblePages()) + { + if (p == -1) + { + ... + } + else + { + + } + } + + + +
+
+
+ +@code { + [Parameter] public int CurrentPage { get; set; } = 1; + [Parameter] public int TotalPages { get; set; } = 1; + [Parameter] public int PageSize { get; set; } = 10; + [Parameter] public int TotalCount { get; set; } + [Parameter] public EventCallback OnPageChanged { get; set; } + [Parameter] public EventCallback OnPageSizeChanged { get; set; } + + private string GetPageButtonClass(int p) => p == CurrentPage + ? "inline-flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold transition-all bg-violet-600 text-white shadow-sm" + : "inline-flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold transition-all text-slate-600 hover:bg-white"; + + private async Task GoToPage(int p) + { + if (p < 1 || p > TotalPages || p == CurrentPage) return; + await OnPageChanged.InvokeAsync(p); + } + + private async Task HandlePageSizeChange(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var size) && size != PageSize) + { + await OnPageSizeChanged.InvokeAsync(size); + } + } + + private IEnumerable GetVisiblePages() + { + if (TotalPages <= 7) + { + return Enumerable.Range(1, TotalPages); + } + + var pages = new List { 1 }; + + int start = Math.Max(2, CurrentPage - 1); + int end = Math.Min(TotalPages - 1, CurrentPage + 1); + + if (start > 2) pages.Add(-1); + for (int i = start; i <= end; i++) pages.Add(i); + if (end < TotalPages - 1) pages.Add(-1); + + pages.Add(TotalPages); + return pages; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/Serialization.cs b/TaskTrackingSystem.WebApp/Components/Partial/Serialization.cs new file mode 100644 index 0000000000000000000000000000000000000000..a1c14c4ae0fb3d6cf0c12ac577240ea4c8371f3e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/Serialization.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace TaskTrackingSystem.WebApp.Components.Partial +{ + public static class Serialization + { + public static readonly JsonSerializerOptions CaseInsensitive = new() + { + PropertyNameCaseInsensitive = true + }; + + public static readonly JsonSerializerOptions PaginationOptions = new() + { + PropertyNameCaseInsensitive = true + }; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/SuccessAlert.razor b/TaskTrackingSystem.WebApp/Components/Partial/SuccessAlert.razor new file mode 100644 index 0000000000000000000000000000000000000000..a8d9c0c07414b7952e865110916f32ea6bc465f9 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/SuccessAlert.razor @@ -0,0 +1,55 @@ +@inject IJSRuntime JS +@implements IDisposable + +@if (!string.IsNullOrEmpty(displayMessage)) +{ +
+
+ + @displayMessage +
+
+} + +@code { + [Parameter] public string? Message { get; set; } + + private string? displayMessage; + private CancellationTokenSource? hideCts; + + protected override async Task OnParametersSetAsync() + { + if (!string.IsNullOrEmpty(Message) && Message != displayMessage) + { + hideCts?.Cancel(); + hideCts?.Dispose(); + hideCts = new CancellationTokenSource(); + + displayMessage = Message; + await InvokeAsync(StateHasChanged); + await JS.InvokeVoidAsync("initIcons"); + + var token = hideCts.Token; + _ = HideAfterDelay(token); + } + } + + private async Task HideAfterDelay(CancellationToken token) + { + try + { + await Task.Delay(3000, token); + displayMessage = null; + await InvokeAsync(StateHasChanged); + } + catch (TaskCanceledException) + { + } + } + + public void Dispose() + { + hideCts?.Cancel(); + hideCts?.Dispose(); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Partial/ThemeToggle.razor b/TaskTrackingSystem.WebApp/Components/Partial/ThemeToggle.razor new file mode 100644 index 0000000000000000000000000000000000000000..725a4d5391c73eea33ee140837a5dc46b7a8fa83 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/ThemeToggle.razor @@ -0,0 +1,8 @@ + diff --git a/TaskTrackingSystem.WebApp/Components/RedirectToLogin.razor b/TaskTrackingSystem.WebApp/Components/RedirectToLogin.razor new file mode 100644 index 0000000000000000000000000000000000000000..b25fb170f8c6899b78fa83bc40e9aaf810b4a971 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/RedirectToLogin.razor @@ -0,0 +1,27 @@ +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated == true) + { + return; + } + + var path = new Uri(Navigation.Uri).AbsolutePath; + if (path.Equals("/login", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var returnUrl = Uri.EscapeDataString(path); + Navigation.NavigateTo($"/login?ReturnUrl={returnUrl}", forceLoad: true); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Routes.razor b/TaskTrackingSystem.WebApp/Components/Routes.razor new file mode 100644 index 0000000000000000000000000000000000000000..405b340a77ed7fad76e686e90f5271203531d72b --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Routes.razor @@ -0,0 +1,24 @@ + + + + +
+ Loading page... +
+
+
+ + + + + + + + + + +

Page not found.

+
+
+
+
diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..4e94490d494d7202d5db64aa65366227b60f7a84 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor @@ -0,0 +1,104 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +
+ @if (!Groups.Any()) + { +
@EmptyText
+ } + else + { + + @foreach (var tick in YTicks) + { + var y = TickToY(tick); + + + @tick.ToString("0") + + } + + @for (var i = 0; i < Groups.Count; i++) + { + var group = Groups[i]; + var slotWidth = (PlotRight - PlotLeft) / Groups.Count; + var centerX = PlotLeft + (slotWidth * i) + (slotWidth / 2); + var bars = group.Bars.Take(MaxBarsPerGroup).ToList(); + var totalBarWidth = (bars.Count * BarWidth) + Math.Max(0, bars.Count - 1) * BarGap; + var startX = centerX - (totalBarWidth / 2); + + + @for (var j = 0; j < bars.Count; j++) + { + var bar = bars[j]; + var height = ValueToHeight(bar.Value); + + } + @group.Label + + } + + + @if (ShowLegend) + { +
+ @foreach (var item in LegendItems) + { +
+ + @item.Label +
+ } +
+ } + } +
+ +@code { + private const double PlotLeft = 56; + private const double PlotRight = 724; + private const double PlotTop = 40; + private const double PlotBottom = 200; + private const double BarGap = 8; + + [Parameter] public IReadOnlyList Groups { get; set; } = Array.Empty(); + [Parameter] public string WrapperClass { get; set; } = "relative h-[280px]"; + [Parameter] public string EmptyText { get; set; } = "No data available"; + [Parameter] public bool ShowLegend { get; set; } = true; + [Parameter] public double BarWidth { get; set; } = 18; + [Parameter] public int MaxBarsPerGroup { get; set; } = 4; + + private double MaxValue => Math.Max(1, Groups.SelectMany(group => group.Bars).Select(bar => bar.Value).DefaultIfEmpty(0).Max()); + + private IReadOnlyList YTicks + { + get + { + var interval = Math.Max(1, Math.Ceiling(MaxValue / 4.0)); + var highest = interval * 4; + return new[] { 0, highest * 0.25, highest * 0.5, highest * 0.75, highest }; + } + } + + private IReadOnlyList LegendItems => Groups + .SelectMany(group => group.Bars) + .GroupBy(bar => bar.Label) + .Select(group => group.First()) + .ToList(); + + private double TickToY(double tickValue) + { + var chartHeight = PlotBottom - PlotTop; + return PlotBottom - (Math.Min(tickValue, MaxValue) / MaxValue * chartHeight); + } + + private double ValueToHeight(double value) + { + var chartHeight = PlotBottom - PlotTop; + return Math.Round(Math.Min(value, MaxValue) / MaxValue * chartHeight, 2); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartCard.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartCard.razor new file mode 100644 index 0000000000000000000000000000000000000000..cf3c4a2ee1b9cc78dfda67b8472c7b61767a7f19 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartCard.razor @@ -0,0 +1,31 @@ +
+
+
+

@Title

+ @if (!string.IsNullOrWhiteSpace(Subtitle)) + { +

@Subtitle

+ } +
+ @if (Actions is not null) + { +
+ @Actions +
+ } +
+ +
+ @ChildContent +
+
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string CardClass { get; set; } = "rounded-xl border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"; + [Parameter] public string TitleClass { get; set; } = "text-xl font-bold text-slate-900 dark:text-slate-50"; + [Parameter] public string BodyClass { get; set; } = "mt-6"; + [Parameter] public RenderFragment? Actions { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartModels.cs b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartModels.cs new file mode 100644 index 0000000000000000000000000000000000000000..63d45f6c96ee1d57450a4a51bca1bafd31771eba --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ChartModels.cs @@ -0,0 +1,23 @@ +namespace TaskTrackingSystem.WebApp.Components.Shared.Charts; + +public sealed record ChartDataPoint(string Label, double Value, string Color = "#7c3aed"); + +public sealed record BarChartGroup(string Label, IReadOnlyList Bars, string? Detail = null); + +public sealed record LineChartPoint(string Label, double Value, string? Detail = null); + +public sealed record ProgressListChartItem( + string Label, + double Value, + string ValueLabel, + string Color = "#7c3aed", + string? Detail = null, + string? LeadingText = null); + +public sealed record TimelineChartItem( + string Label, + DateTime StartDate, + DateTime EndDate, + double CompletionPercentage, + string Color = "#7c3aed", + string? Detail = null); diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/DonutChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/DonutChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..99defbc5db710d70a2c428bfeea83794663aac50 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/DonutChart.razor @@ -0,0 +1,100 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +
+ @if (!Items.Any() || Items.Sum(item => item.Value) <= 0) + { +
@EmptyText
+ } + else + { +
+ + @foreach (var slice in Slices) + { + if (slice.IsFullCircle) + { + + } + else + { + + } + + } + +
+ + @if (ShowLegend) + { +
+ @foreach (var item in Items) + { +
+
+ + @item.Label +
+ @item.Value.ToString(ValueFormat) +
+ } +
+ } + } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public bool ShowLegend { get; set; } = true; + [Parameter] public string EmptyText { get; set; } = "No data available"; + [Parameter] public string ValueFormat { get; set; } = "0"; + [Parameter] public string WrapperClass { get; set; } = string.Empty; + + private IReadOnlyList Slices => BuildSlices(); + + private IReadOnlyList BuildSlices() + { + const double center = 95; + const double radius = 72; + var total = Items.Sum(item => Math.Max(0, item.Value)); + var startAngle = -90.0; + var slices = new List(); + + foreach (var item in Items) + { + var value = Math.Max(0, item.Value); + if (value <= 0 || total <= 0) + { + continue; + } + + var sweep = 360 * value / total; + var endAngle = startAngle + sweep; + + if (sweep >= 359.999) + { + slices.Add(new Slice(item.Color, string.Empty, true)); + startAngle = endAngle; + continue; + } + + var start = PointOnCircle(center, center, radius, startAngle); + var end = PointOnCircle(center, center, radius, endAngle); + var largeArc = sweep > 180 ? 1 : 0; + var path = $"M {center:0.##} {center:0.##} L {start.X:0.##} {start.Y:0.##} A {radius:0.##} {radius:0.##} 0 {largeArc} 1 {end.X:0.##} {end.Y:0.##} Z"; + + slices.Add(new Slice(item.Color, path, false)); + + startAngle = endAngle; + } + + return slices; + } + + private static (double X, double Y) PointOnCircle(double centerX, double centerY, double radius, double angleDegrees) + { + var radians = angleDegrees * Math.PI / 180; + return (centerX + radius * Math.Cos(radians), centerY + radius * Math.Sin(radians)); + } + + private sealed record Slice(string Color, string Path, bool IsFullCircle); +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/GaugeChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/GaugeChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..7f1b8b6104bc081e38ac6e034bb048aee33213e0 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/GaugeChart.razor @@ -0,0 +1,34 @@ +
+
+ + + + +
+
@ClampedValue.ToString("0")%
+ @if (!string.IsNullOrWhiteSpace(Label)) + { +
@Label
+ } +
+
+
+ +@code { + [Parameter] public double Value { get; set; } + [Parameter] public string? Label { get; set; } + [Parameter] public string Color { get; set; } = "#7c3aed"; + [Parameter] public string WrapperClass { get; set; } = string.Empty; + + private double ClampedValue => Math.Clamp(Value, 0, 100); + private string DashArray + { + get + { + const double radius = 70; + var circumference = 2 * Math.PI * radius; + var arc = circumference * ClampedValue / 100; + return $"{arc:0.##} {(circumference - arc):0.##}"; + } + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/LineChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/LineChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..00e72146a97866ab5d22128588feca5bb13258e5 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/LineChart.razor @@ -0,0 +1,87 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +
+ @if (Points.Count < 2) + { +
@EmptyText
+ } + else + { + + + + + + + + + @for (var i = 0; i < 5; i++) + { + var y = 60 + (i * 55); + + } + + @for (var i = 0; i < 6; i++) + { + var x = 60 + (i * 125); + + } + + + + + @foreach (var point in SvgPoints) + { + + } + + +
+ @foreach (var point in Points) + { + @point.Label + } +
+ } +
+ +@code { + private readonly string GradientId = $"lineFill{Guid.NewGuid():N}"; + + [Parameter] public IReadOnlyList Points { get; set; } = Array.Empty(); + [Parameter] public string Color { get; set; } = "#2563eb"; + [Parameter] public string EmptyText { get; set; } = "No data available"; + [Parameter] public string WrapperClass { get; set; } = string.Empty; + + private IReadOnlyList<(double X, double Y)> SvgPoints => BuildSvgPoints(); + private string LinePath => BuildLinePath(); + private string AreaPath => string.IsNullOrWhiteSpace(LinePath) + ? string.Empty + : $"{LinePath} L {SvgPoints.Last().X:0},{280:0} L {SvgPoints.First().X:0},{280:0} Z"; + + private IReadOnlyList<(double X, double Y)> BuildSvgPoints() + { + const double left = 48; + const double right = 730; + const double top = 48; + const double bottom = 280; + var max = Math.Max(1, Points.Select(point => point.Value).DefaultIfEmpty(0).Max()); + + return Points.Select((point, index) => + { + var x = left + (index * ((right - left) / Math.Max(Points.Count - 1, 1))); + var y = bottom - Math.Min(point.Value, max) / max * (bottom - top); + return (x, y); + }).ToList(); + } + + private string BuildLinePath() + { + if (!SvgPoints.Any()) + { + return string.Empty; + } + + return $"M {string.Join(" L ", SvgPoints.Select(point => $"{point.X:0},{point.Y:0}"))}"; + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/ProgressListChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ProgressListChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..52780edfa874179d03063b833870bfc67493e694 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ProgressListChart.razor @@ -0,0 +1,47 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +
+ @if (!Items.Any()) + { +
+ @EmptyText +
+ } + else + { + @foreach (var item in Items) + { + var width = Math.Clamp(item.Value, 0, MaxValue) / MaxValue * 100; +
+
+ @if (!string.IsNullOrWhiteSpace(item.LeadingText)) + { +
+ @item.LeadingText +
+ } +
+
+
@item.Label
+
@item.ValueLabel
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(item.Detail)) + { +
@item.Detail
+ } +
+
+
+ } + } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public double MaxValue { get; set; } = 100; + [Parameter] public string EmptyText { get; set; } = "No data available."; + [Parameter] public string WrapperClass { get; set; } = "space-y-4"; +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/TimelineChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/TimelineChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..8ec945a3b437fed0dc23fe16ff2a4c249581fe3a --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/TimelineChart.razor @@ -0,0 +1,88 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +
+ @if (!Items.Any()) + { +
@EmptyText
+ } + else + { +
+
+ @foreach (var month in Months) + { + @month.ToString("MMM") + } +
+ + @foreach (var item in Bars) + { +
+
@item.Label
+
+
+
+
+
+
+ } +
+ } +
+ +@code { + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + [Parameter] public string EmptyText { get; set; } = "No timeline data available."; + [Parameter] public string WrapperClass { get; set; } = "overflow-auto"; + + private IReadOnlyList Months => BuildMonths(); + private string GridStyle => $"grid-template-columns: 180px repeat({Math.Max(Months.Count, 1)}, minmax(56px, 1fr));"; + private IReadOnlyList Bars => BuildBars(); + + private IReadOnlyList BuildMonths() + { + if (!Items.Any()) + { + return Array.Empty(); + } + + var start = new DateTime(Items.Min(item => item.StartDate).Year, Items.Min(item => item.StartDate).Month, 1); + var end = new DateTime(Items.Max(item => item.EndDate).Year, Items.Max(item => item.EndDate).Month, 1); + var months = new List(); + + for (var current = start; current <= end; current = current.AddMonths(1)) + { + months.Add(current); + } + + return months; + } + + private IReadOnlyList BuildBars() + { + if (!Months.Any()) + { + return Array.Empty(); + } + + var first = Months.First(); + var slots = Months.Count; + + return Items.Select(item => + { + var startIndex = ((item.StartDate.Year - first.Year) * 12) + item.StartDate.Month - first.Month; + var endIndex = ((item.EndDate.Year - first.Year) * 12) + item.EndDate.Month - first.Month; + startIndex = Math.Clamp(startIndex, 0, slots - 1); + endIndex = Math.Clamp(endIndex, startIndex, slots - 1); + + return new TimelineBar( + item.Label, + startIndex / (double)slots * 100, + Math.Max(2, (endIndex - startIndex + 1) / (double)slots * 100), + Math.Clamp(item.CompletionPercentage, 0, 100), + item.Color); + }).ToList(); + } + + private sealed record TimelineBar(string Label, double Left, double Width, double CompletionPercentage, string Color); +} diff --git a/TaskTrackingSystem.WebApp/Components/_Imports.razor b/TaskTrackingSystem.WebApp/Components/_Imports.razor new file mode 100644 index 0000000000000000000000000000000000000000..7d2691576493b01f085e89c647d6b3ddfb460bd7 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/_Imports.razor @@ -0,0 +1,38 @@ +@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 TaskTrackingSystem.WebApp +@using TaskTrackingSystem.WebApp.Components +@using TaskTrackingSystem.WebApp.Components.Partial +@using TaskTrackingSystem.Shared +@using TaskTrackingSystem.Shared.Localization +@using TaskTrackingSystem.Shared.Models.User +@using TaskTrackingSystem.Shared.Models.Project +@using TaskTrackingSystem.Shared.Models.Task +@using TaskTrackingSystem.Shared.Models.Role +@using TaskTrackingSystem.Shared.Models.Dashboard +@using TaskTrackingSystem.Shared.Models.Report +@using TaskTrackingSystem.Shared.Models.Auth +@using TaskTrackingSystem.Shared.Models.Notification +@using TaskTrackingSystem.Shared.Models.Menu +@using Microsoft.AspNetCore.Components.Authorization +@using TaskTrackingSystem.Shared.Enums +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Dashboard.Components +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Tasks +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Projects +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Issues +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Reports +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Reports.Components +@using TaskTrackingSystem.WebApp.Components.Shared.Charts +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Roles +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Users +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Auth +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Audit +@using TaskTrackingSystem.WebApp.Components.Pages.Features.Common +@attribute [StreamRendering] diff --git a/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs b/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs new file mode 100644 index 0000000000000000000000000000000000000000..115dafa05d0d51e9a644ccd03722fb1750b59daf --- /dev/null +++ b/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using Microsoft.JSInterop; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.WebApp.Localization; + +public enum AppLanguage +{ + English, + Burmese +} + +public sealed class UiTextBundle +{ + public required string Workspace { get; init; } + public required string SignIn { get; init; } + public required string SignOut { get; init; } + public required string SignOutTitle { get; init; } + public required string SignOutDescription { get; init; } + public required string Cancel { get; init; } + public required string ConfirmSignOut { get; init; } + public required string AccessDenied { get; init; } + public required string AccessDeniedDescription { get; init; } + public required string BackToDashboard { get; init; } + public required string NoAccessItems { get; init; } + public required string AuditLogs { get; init; } + public required string LanguageEnglish { get; init; } + public required string LanguageBurmese { get; init; } + public required string LanguageTitle { get; init; } +} + +public sealed class UiLanguageService +{ + private const string StorageKey = "tts-language"; + private readonly IJSRuntime _js; + + public UiLanguageService(IJSRuntime js) + { + _js = js; + } + + public AppLanguage CurrentLanguage { get; private set; } = AppLanguage.English; + + public UiTextBundle Texts => new() + { + Workspace = AppLocalization.Text("common.workspace", "Workspace"), + SignIn = AppLocalization.Text("common.signIn", "Sign In"), + SignOut = AppLocalization.Text("common.signOut", "Sign Out"), + SignOutTitle = AppLocalization.Text("common.signOutTitle", "Sign Out?"), + SignOutDescription = AppLocalization.Text("common.signOutDescription", "Are you sure you want to sign out of Taskify?"), + Cancel = AppLocalization.Text("common.cancel", "Cancel"), + ConfirmSignOut = AppLocalization.Text("common.confirmSignOut", "Yes, Sign Out"), + AccessDenied = AppLocalization.Text("common.accessDenied", "Access Denied"), + AccessDeniedDescription = AppLocalization.Text("common.accessDeniedDescription", "You do not have permission to access this page. Please contact your system administrator."), + BackToDashboard = AppLocalization.Text("common.backToDashboard", "Back to Dashboard"), + NoAccessItems = AppLocalization.Text("common.noAccessItems", "No access items are available for this role."), + AuditLogs = AppLocalization.Text("page.auditLogs", "Audit Logs"), + LanguageEnglish = AppLocalization.Text("common.languageEnglish", "English"), + LanguageBurmese = AppLocalization.Text("common.languageBurmese", "Burmese"), + LanguageTitle = AppLocalization.Text("common.currentLanguage", "Switch language") + }; + + public event Action? Changed; + + public async Task InitializeAsync() + { + var code = await _js.InvokeAsync("languagePreference.init", StorageKey); + SetLanguageFromCode(code); + } + + public async Task ToggleAsync() + { + var code = await _js.InvokeAsync("languagePreference.toggle", StorageKey); + SetLanguageFromCode(code); + } + + public async Task SetAsync(AppLanguage language) + { + var code = ToCode(language); + await _js.InvokeVoidAsync("languagePreference.set", StorageKey, code); + SetLanguageFromCode(code); + } + + public string GetCode() => ToCode(CurrentLanguage); + + public static string ToCode(AppLanguage language) => language == AppLanguage.Burmese ? "my-MM" : "en-US"; + + public static AppLanguage FromCode(string? code) => + string.Equals(code, "my", StringComparison.OrdinalIgnoreCase) || + string.Equals(code, "my-MM", StringComparison.OrdinalIgnoreCase) + ? AppLanguage.Burmese + : AppLanguage.English; + + public string T(string key, string fallback = "") => AppLocalization.Text(key, fallback); + public string Menu(string menuCode, string fallback = "") => AppLocalization.Menu(menuCode, fallback); + public string PageTitle(string key, string fallback = "") => AppLocalization.PageTitle(key, fallback); + public string PageDescription(string key, string fallback = "") => AppLocalization.PageDescription(key, fallback); + public string StatusLabel(TaskTrackingSystem.Shared.Enums.AppTaskStatus status) => AppLocalization.StatusLabel(status); + public string PriorityLabel(TaskTrackingSystem.Shared.Enums.TaskPriority priority) => AppLocalization.PriorityLabel(priority); + public string EscalationLabel(int level) => AppLocalization.EscalationLabel(level); + + private void SetLanguageFromCode(string? code) + { + var next = FromCode(code); + ApplyCulture(next); + + if (next == CurrentLanguage) + { + return; + } + + CurrentLanguage = next; + Changed?.Invoke(); + } + + private static void ApplyCulture(AppLanguage language) + { + var culture = language == AppLanguage.Burmese ? CultureInfo.GetCultureInfo("my-MM") : CultureInfo.GetCultureInfo("en-US"); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + } +} diff --git a/TaskTrackingSystem.WebApp/MenuAuthorizationService.cs b/TaskTrackingSystem.WebApp/MenuAuthorizationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..1d790aed34edce8f7d778b3d30f167faf1801b07 --- /dev/null +++ b/TaskTrackingSystem.WebApp/MenuAuthorizationService.cs @@ -0,0 +1,483 @@ +using System.Net.Http.Json; +using System.Security.Claims; +using TaskTrackingSystem.Shared.Models.Menu; + +namespace TaskTrackingSystem.WebApp; + +public class MenuAuthorizationService +{ + private static readonly HashSet PublicRouteSegments = new(StringComparer.OrdinalIgnoreCase) + { + string.Empty, + "login", + "register", + "error", + "reset-password" + }; + + private readonly ApiClientService _apiClient; + private readonly UserSessionState _sessionState; + private Task>? _loadingAccessItems; + private Task>? _loadingAccessCodes; + + public MenuAuthorizationService(ApiClientService apiClient, UserSessionState sessionState) + { + _apiClient = apiClient; + _sessionState = sessionState; + } + + public static bool IsPublicRoute(string relativePath) + { + return PublicRouteSegments.Contains(GetFirstSegment(relativePath)); + } + + public static string GetFirstSegment(string relativePath) + { + var path = relativePath.Split('?')[0].Trim('/'); + if (string.IsNullOrEmpty(path)) + { + return string.Empty; + } + + return path.Split('/')[0]; + } + + public static string NormalizeMenuHref(string? menuUrl) + { + if (string.IsNullOrWhiteSpace(menuUrl)) + { + return "/dashboard"; + } + + return menuUrl.StartsWith('/') ? menuUrl : $"/{menuUrl.TrimStart('/')}"; + } + + public bool IsRouteAllowed(ClaimsPrincipal user, string relativePath) + { + if (user.Identity?.IsAuthenticated != true) + { + return IsPublicRoute(relativePath); + } + + if (IsPublicRoute(relativePath)) + { + return true; + } + + var cleanRelative = relativePath.Split('?')[0].Trim('/'); + if (cleanRelative.Equals("audit-logs", StringComparison.OrdinalIgnoreCase)) + { + if (_sessionState.CachedAccessCodes != null && _sessionState.CachedAccessCodes.Contains("AuditLogs_List")) + { + return true; + } + + if (_sessionState.CachedAccessItems != null) + { + return IsRouteAllowed(_sessionState.CachedAccessItems, relativePath); + } + + return false; + } + + var roleKey = GetRoleCacheKey(user); + + if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) + { + return IsRouteAllowed(_sessionState.CachedAccessItems, relativePath); + } + + return false; + } + + public bool IsRouteAllowed(IReadOnlyList accessItems, string relativePath) + { + var cleanRelative = relativePath.Split('?')[0].Trim('/'); + var firstSegment = cleanRelative.Split('/')[0]; + + if (PublicRouteSegments.Contains(firstSegment)) + { + return true; + } + + // Special case for Task Details page route parameter wildcard + if (cleanRelative.StartsWith("tasks/", StringComparison.OrdinalIgnoreCase) && + cleanRelative.EndsWith("/details", StringComparison.OrdinalIgnoreCase)) + { + var segments = cleanRelative.Split('/'); + if (segments.Length == 3 && long.TryParse(segments[1], out _)) + { + var hasTasksAccess = accessItems.Any(m => + m.MenuCode.Equals("TASKS_LIST", StringComparison.OrdinalIgnoreCase) || + m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || + m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_LIST", StringComparison.OrdinalIgnoreCase) || + sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); + if (hasTasksAccess) + { + return true; + } + } + } + + // Special case for project-specific Kanban board + if (cleanRelative.StartsWith("projects/", StringComparison.OrdinalIgnoreCase) && + cleanRelative.EndsWith("/tasks", StringComparison.OrdinalIgnoreCase)) + { + var segments = cleanRelative.Split('/'); + if (segments.Length == 3 && long.TryParse(segments[1], out _)) + { + var hasBoardAccess = accessItems.Any(m => + m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || + m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); + if (hasBoardAccess) + { + return true; + } + } + } + + // Global board route + if (cleanRelative.Equals("board", StringComparison.OrdinalIgnoreCase)) + { + var hasBoardAccess = accessItems.Any(m => + m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || + m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); + + if (hasBoardAccess) + { + return true; + } + } + + if (string.IsNullOrEmpty(cleanRelative) || + cleanRelative.Equals("dashboard", StringComparison.OrdinalIgnoreCase) || + cleanRelative.Equals("home", StringComparison.OrdinalIgnoreCase)) + { + return MenuCollectionMatchesRoute(accessItems, "dashboard") || + MenuCollectionMatchesRoute(accessItems, ""); + } + + return MenuCollectionMatchesRoute(accessItems, cleanRelative); + } + + public Task> GetUserMenusAsync(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(new List()); + } + + var roleKey = GetRoleCacheKey(user); + + if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) + { + return Task.FromResult(_sessionState.CachedAccessItems); + } + + return LoadAccessItemsAsync(user, roleKey); + } + + public async Task HasAccessCodeAsync(ClaimsPrincipal user, string accessCode) + { + if (string.IsNullOrWhiteSpace(accessCode)) + { + return false; + } + + var codes = await GetCurrentAccessCodesAsync(user); + return codes.Contains(accessCode.Trim()); + } + + public Task> GetCurrentAccessCodesAsync(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + var roleKey = GetRoleCacheKey(user); + + if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessCodes != null) + { + return Task.FromResult(_sessionState.CachedAccessCodes); + } + + return LoadAccessCodesAsync(user, roleKey); + } + + public void PreloadMenus(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return; + } + + var roleKey = GetRoleCacheKey(user); + if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) + { + return; + } + + _ = LoadAccessItemsAsync(user, roleKey); + } + + /// + /// When access items are cached, quickly checks the server-side version to see if an admin changed permissions. + /// If the version changed, busts the cache and re-fetches the full menu list. + /// Falls back to the cached access items if the version check itself fails (e.g. network error). + /// + private async Task> LoadAccessItemsWithVersionCheckAsync(ClaimsPrincipal user, string roleKey) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var client = _apiClient.CreateClient(user); + var response = await client.GetAsync("Menu/version", cts.Token); + + if (response.IsSuccessStatusCode) + { + var serverVersion = await response.Content.ReadAsStringAsync(cts.Token); + serverVersion = serverVersion.Trim('"'); + + if (serverVersion != _sessionState.CachedAccessVersion) + { + Console.WriteLine($"[MenuAuth] Access version changed ({_sessionState.CachedAccessVersion} -> {serverVersion}). Reloading access items."); + _sessionState.ClearAccessCache(); + return await LoadAccessItemsAsync(user, roleKey); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[MenuAuth] Access version check failed (using cache): {ex.Message}"); + } + + return _sessionState.CachedAccessItems!; + } + + private Task> LoadAccessItemsAsync(ClaimsPrincipal user, string roleKey) + { + if (_loadingAccessItems is { IsCompleted: false }) + { + return _loadingAccessItems; + } + + _loadingAccessItems = FetchAccessItemsFromApiAsync(user, roleKey); + return _loadingAccessItems; + } + + private async Task> LoadAccessCodesWithVersionCheckAsync(ClaimsPrincipal user, string roleKey) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var client = _apiClient.CreateClient(user); + var response = await client.GetAsync("Menu/version", cts.Token); + + if (response.IsSuccessStatusCode) + { + var serverVersion = await response.Content.ReadAsStringAsync(cts.Token); + serverVersion = serverVersion.Trim('"'); + + if (serverVersion != _sessionState.CachedAccessVersion) + { + _sessionState.ClearAccessCache(); + return await LoadAccessCodesAsync(user, roleKey); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[MenuAuth] Access code version check failed (using cache): {ex.Message}"); + } + + return _sessionState.CachedAccessCodes ?? new HashSet(StringComparer.OrdinalIgnoreCase); + } + + private Task> LoadAccessCodesAsync(ClaimsPrincipal user, string roleKey) + { + if (_loadingAccessCodes is { IsCompleted: false }) + { + return _loadingAccessCodes; + } + + _loadingAccessCodes = FetchAccessCodesFromApiAsync(user, roleKey); + return _loadingAccessCodes; + } + + private async Task> FetchAccessCodesFromApiAsync(ClaimsPrincipal user, string roleKey) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var client = _apiClient.CreateClient(user); + + var codesResponse = await client.GetAsync("Menu/access-codes", cts.Token); + if (!codesResponse.IsSuccessStatusCode) + { + Console.WriteLine($"Menu access-codes API failed: {(int)codesResponse.StatusCode} {codesResponse.ReasonPhrase}"); + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var codes = await codesResponse.Content.ReadFromJsonAsync>(cancellationToken: cts.Token) + ?? new List(); + + var versionResponse = await client.GetAsync("Menu/version", cts.Token); + if (versionResponse.IsSuccessStatusCode) + { + var version = await versionResponse.Content.ReadAsStringAsync(cts.Token); + _sessionState.CachedAccessVersion = version.Trim('"'); + } + + var codeSet = new HashSet(codes.Where(code => !string.IsNullOrWhiteSpace(code)).Select(code => code.Trim()), StringComparer.OrdinalIgnoreCase); + _sessionState.CachedAccessCodes = codeSet; + _sessionState.CachedAccessRoleId = roleKey; + return codeSet; + } + catch (Exception ex) + { + Console.WriteLine($"Menu access-codes API error: {ex.Message}"); + var fallback = new HashSet(StringComparer.OrdinalIgnoreCase); + _sessionState.CachedAccessCodes = fallback; + _sessionState.CachedAccessRoleId = roleKey; + return fallback; + } + finally + { + _loadingAccessCodes = null; + } + } + + private async Task> FetchAccessItemsFromApiAsync(ClaimsPrincipal user, string roleKey) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var client = _apiClient.CreateClient(user); + + // Fetch access items and version in parallel for efficiency. + var accessItemsTask = client.GetAsync("Menu", cts.Token); + var versionTask = client.GetAsync("Menu/version", cts.Token); + + await Task.WhenAll(accessItemsTask, versionTask); + + var menuResponse = await accessItemsTask; + if (!menuResponse.IsSuccessStatusCode) + { + Console.WriteLine($"Menu API failed: {(int)menuResponse.StatusCode} {menuResponse.ReasonPhrase}"); + return new List(); + } + + var accessItems = await menuResponse.Content.ReadFromJsonAsync>(cancellationToken: cts.Token) + ?? new List(); + + if (accessItems.Count == 0) + { + accessItems = BuildFallbackMenus(user); + } + + var versionResponse = await versionTask; + if (versionResponse.IsSuccessStatusCode) + { + var version = await versionResponse.Content.ReadAsStringAsync(cts.Token); + _sessionState.CachedAccessVersion = version.Trim('"'); + } + + _sessionState.CachedAccessItems = accessItems; + _sessionState.CachedAccessRoleId = roleKey; + return accessItems; + } + catch (Exception ex) + { + Console.WriteLine($"Menu API error: {ex.Message}"); + var fallbackAccessItems = BuildFallbackMenus(user); + _sessionState.CachedAccessItems = fallbackAccessItems; + _sessionState.CachedAccessRoleId = roleKey; + return fallbackAccessItems; + } + finally + { + _loadingAccessItems = null; + } + } + + private static bool MenuCollectionMatchesRoute(IReadOnlyList accessItems, string relativePath) + { + foreach (var menu in accessItems) + { + if (MenuMatchesRoute(menu, relativePath)) + { + return true; + } + + foreach (var subMenu in menu.SubMenus) + { + if (MenuMatchesRoute(subMenu, relativePath)) + { + return true; + } + } + } + + return false; + } + + private static bool MenuMatchesRoute(MenuDto menu, string relativePath) + { + if (string.IsNullOrWhiteSpace(menu.MenuUrl)) + { + return false; + } + + var cleanMenu = menu.MenuUrl.Split('?')[0].Trim('/'); + var cleanRelative = relativePath.Split('?')[0].Trim('/'); + + var menuSegments = cleanMenu.Split('/'); + var relativeSegments = cleanRelative.Split('/'); + + if (menuSegments.Length != relativeSegments.Length) + { + return false; + } + + for (int i = 0; i < menuSegments.Length; i++) + { + var menuSeg = menuSegments[i]; + var relSeg = relativeSegments[i]; + + if (menuSeg.StartsWith('{') && menuSeg.EndsWith('}')) + { + continue; // Route parameter wildcard + } + + if (!menuSeg.Equals(relSeg, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static List BuildFallbackMenus(ClaimsPrincipal user) + { + return new List(); + } + + private static string GetRoleCacheKey(ClaimsPrincipal user) + { + var roleId = user.FindFirst("role_id")?.Value; + if (!string.IsNullOrWhiteSpace(roleId)) + { + return $"id:{roleId}"; + } + + var roleName = user.FindFirst(ClaimTypes.Role)?.Value; + if (!string.IsNullOrWhiteSpace(roleName)) + { + return $"name:{roleName}"; + } + + return "unknown"; + } +} diff --git a/TaskTrackingSystem.WebApp/Program.cs b/TaskTrackingSystem.WebApp/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..06ed972443c0c756271fe55d1aea6d04cde1fa1d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Program.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using TaskTrackingSystem.WebApp.Localization; +using TaskTrackingSystem.WebApp; +using TaskTrackingSystem.WebApp.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(options => options.DetailedErrors = true); + +// Register HttpClient for WebApi calls +var webApiBaseUrl = builder.Configuration["WebApi:BaseUrl"] ?? "http://127.0.0.1:5001/api/"; + +var webApiBuilder = builder.Services.AddHttpClient("WebApi", client => +{ + client.BaseAddress = new Uri(webApiBaseUrl); + client.Timeout = TimeSpan.FromSeconds( + builder.Configuration.GetValue("WebApi:TimeoutSeconds", builder.Environment.IsDevelopment() ? 30 : 60)); +}); + +webApiBuilder.ConfigurePrimaryHttpMessageHandler(() => +{ + var handler = new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromSeconds( + builder.Configuration.GetValue("WebApi:ConnectTimeoutSeconds", builder.Environment.IsDevelopment() ? 5 : 15)), + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }; + + if (builder.Environment.IsDevelopment() && + webApiBaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } + + return handler; +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Cookie authentication for Blazor pages and HTTP middleware. +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.AccessDeniedPath = "/login"; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + }); +builder.Services.AddAuthorization(); +builder.Services.AddAuthorizationCore(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapAccountEndpoints(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/TaskTrackingSystem.WebApp/Properties/launchSettings.json b/TaskTrackingSystem.WebApp/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..1fc6b9b044fb25c9c9e0bfe623355b6e89ea98dc --- /dev/null +++ b/TaskTrackingSystem.WebApp/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3907", + "sslPort": 44355 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5247", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7176;http://localhost:5247", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj b/TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj new file mode 100644 index 0000000000000000000000000000000000000000..f59fa8df8417682e29631dcdbe62543fa50fd76d --- /dev/null +++ b/TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/TaskTrackingSystem.WebApp/UserSessionState.cs b/TaskTrackingSystem.WebApp/UserSessionState.cs new file mode 100644 index 0000000000000000000000000000000000000000..a6944d8809394ef5590b1aa50bb73ed3a4bf1ab7 --- /dev/null +++ b/TaskTrackingSystem.WebApp/UserSessionState.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using TaskTrackingSystem.Shared.Models.Menu; + +namespace TaskTrackingSystem.WebApp; + +public class UserSessionState +{ + public string? Token { get; set; } + public ClaimsPrincipal? CachedUser { get; set; } + + public string? CachedAccessRoleId { get; set; } + public List? CachedAccessItems { get; set; } + public HashSet? CachedAccessCodes { get; set; } + + /// + /// The server-side permissions version (latest role access timestamp) when the access items were last fetched. + /// Used to detect when an admin has changed a role's permissions so the cache can be busted. + /// + public string? CachedAccessVersion { get; set; } + + public void ClearAccessCache() + { + CachedAccessRoleId = null; + CachedAccessItems = null; + CachedAccessCodes = null; + CachedAccessVersion = null; + } + + public void ClearSession() + { + Token = null; + CachedUser = null; + ClearAccessCache(); + } +} + diff --git a/TaskTrackingSystem.WebApp/appsettings.Development.json b/TaskTrackingSystem.WebApp/appsettings.Development.json new file mode 100644 index 0000000000000000000000000000000000000000..dd3d36cabd5444beb44a4a412e89af05e5e6ed90 --- /dev/null +++ b/TaskTrackingSystem.WebApp/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "WebApi": { + "BaseUrl": "http://127.0.0.1:5018/api/" + } +} \ No newline at end of file diff --git a/TaskTrackingSystem.WebApp/appsettings.json b/TaskTrackingSystem.WebApp/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..20d9c5616b851086f920325db98ecbd2ef226836 --- /dev/null +++ b/TaskTrackingSystem.WebApp/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "WebApi": { + "BaseUrl": "http://127.0.0.1:5001/api/" + } +} diff --git a/TaskTrackingSystem.WebApp/wwwroot/app.css b/TaskTrackingSystem.WebApp/wwwroot/app.css new file mode 100644 index 0000000000000000000000000000000000000000..0c5b957e04b8a6e7271cf22b39f82fa8c452f161 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/app.css @@ -0,0 +1,1778 @@ +:root { + --brand-primary: #0F766E; + --brand-primary-hover: #115E59; + --brand-accent: #B45309; + --app-bg: #F7F8F7; + --surface: #FFFFFF; + --surface-muted: #F1F5F4; + --surface-raised: #FFFFFF; + --border: #DDE5E2; + --border-strong: #B9C8C3; + --text-primary: #17211F; + --text-muted: #64706D; + --text-soft: #8B9894; + --focus-ring: rgba(15, 118, 110, 0.24); + --status-success-bg: #DFF6EE; + --status-success-text: #0F766E; + --status-warning-bg: #FEF3D2; + --status-warning-text: #9A5B00; + --status-danger-bg: #FCE4E1; + --status-danger-text: #B42318; + --sidebar-bg: #19211F; + --sidebar-muted: #A5B2AE; + --sidebar-active: rgba(15, 118, 110, 0.22); + --sidebar-border: rgba(221, 229, 226, 0.12); + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.5rem; + --shadow-card: 0 1px 2px rgba(23, 33, 31, 0.06), 0 12px 28px rgba(23, 33, 31, 0.045); + --shadow-popover: 0 18px 44px rgba(23, 33, 31, 0.18); +} + +html.dark { + --brand-primary: #5EEAD4; + --brand-primary-hover: #2DD4BF; + --brand-accent: #FCD34D; + --app-bg: #101614; + --surface: #18201E; + --surface-muted: #202A27; + --surface-raised: #1B2522; + --border: #2E3B37; + --border-strong: #43524D; + --text-primary: #F4F7F6; + --text-muted: #B4C0BC; + --text-soft: #889692; + --focus-ring: rgba(94, 234, 212, 0.28); + --status-success-bg: rgba(45, 212, 191, 0.16); + --status-success-text: #99F6E4; + --status-warning-bg: rgba(252, 211, 77, 0.16); + --status-warning-text: #FDE68A; + --status-danger-bg: rgba(248, 113, 113, 0.16); + --status-danger-text: #FCA5A5; + --sidebar-bg: #111816; + --sidebar-muted: #9DACA7; + --sidebar-active: rgba(94, 234, 212, 0.15); + --sidebar-border: rgba(221, 229, 226, 0.12); + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.34); + --shadow-popover: 0 18px 50px rgba(0, 0, 0, 0.42); + color-scheme: dark; +} + +html, body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background-color: var(--app-bg); + color: var(--text-primary); + scrollbar-gutter: stable; + font-size: 15px; + letter-spacing: 0; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body.tts-sidebar-open { + overflow: hidden; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.72) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(100, 116, 139, 0.72); + border: 2px solid transparent; + border-radius: 999px; + background-clip: content-box; + min-height: 40px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: rgba(71, 85, 105, 0.92); + background-clip: content-box; +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +a, .btn-link { + color: var(--brand-primary); +} + +.btn-primary { + color: #fff; + background-color: var(--brand-primary); + border-color: var(--brand-primary); +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem var(--surface), 0 0 0 0.25rem var(--focus-ring); +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #111827; +} + +.invalid { + outline: 1px solid #991B1B; +} + +.validation-message { + color: #991B1B; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC51NDUgOTIuNzk1IDI5MC5hNTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNjg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyICcuODc5IDY5LjMxNzkgMjY3LjgyOSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA5Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #240046; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +html.dark a, +html.dark .btn-link { + color: #93C5FD; +} + +html.dark, +html.dark body { + background-color: var(--app-bg); + color: var(--text-primary); + scrollbar-color: rgba(148, 163, 184, 0.82) rgba(15, 23, 42, 0.9); +} + +html.dark *::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.9); +} + +html.dark *::-webkit-scrollbar-thumb { + background-color: rgba(148, 163, 184, 0.82); +} + +html.dark *::-webkit-scrollbar-thumb:hover { + background-color: rgba(226, 232, 240, 0.9); +} + +html.dark .bg-white, +html.dark .bg-card, +html.dark .bg-popover { + background-color: #111827 !important; +} + +html.dark .bg-slate-50, +html.dark .bg-slate-50\/50, +html.dark .bg-slate-50\/60, +html.dark .bg-slate-50\/70, +html.dark .bg-muted { + background-color: #1E293B !important; +} + +html.dark .bg-background, +html.dark .bg-slate-100, +html.dark .bg-slate-100\/50, +html.dark .bg-slate-200, +html.dark .bg-slate-300 { + background-color: #334155 !important; +} + +html.dark .bg-slate-400, +html.dark .bg-slate-500 { + background-color: #475569 !important; +} + +html.dark .bg-slate-800, +html.dark .bg-slate-900, +html.dark .bg-slate-950 { + background-color: #0F172A !important; +} + +html.dark .text-foreground, +html.dark .text-card-foreground, +html.dark .text-popover-foreground, +html.dark .text-primary, +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #F8FAFC !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #CBD5E1 !important; +} + +html.dark .text-muted-foreground, +html.dark .text-muted, +html.dark .text-slate-400, +html.dark .text-slate-300 { + color: #94A3B8 !important; +} + +html.dark .border-slate-100, +html.dark .border-slate-200, +html.dark .border-slate-300, +html.dark .border-slate-800, +html.dark .border-border, +html.dark .divide-slate-100, +html.dark .divide-slate-200 { + border-color: #334155 !important; +} + +html.dark .border-red-500, +html.dark .border-rose-500 { + border-color: #FB7185 !important; +} + +html.dark .border-violet-500, +html.dark .border-violet-600, +html.dark .border-indigo-500, +html.dark .border-indigo-600 { + border-color: #93C5FD !important; +} + +html.dark input, +html.dark select, +html.dark textarea, +html.dark .form-control, +html.dark .form-select { + background-color: #0F172A !important; + color: #F8FAFC !important; + border-color: #334155 !important; +} + +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94A3B8 !important; +} + +html.dark input[type="checkbox"], +html.dark input[type="radio"] { + accent-color: #2563EB; +} + +html.dark .btn-primary, +html.dark .bg-primary, +html.dark .bg-violet-500, +html.dark .bg-violet-600, +html.dark .bg-purple-500, +html.dark .bg-purple-600, +html.dark .bg-indigo-500, +html.dark .bg-indigo-600 { + background-color: #2563EB !important; + border-color: #4338CA !important; + color: #FFFFFF !important; +} + +html.dark .bg-violet-700, +html.dark .bg-purple-700, +html.dark .bg-indigo-700 { + background-color: #4338CA !important; + color: #FFFFFF !important; +} + +html.dark .bg-violet-400, +html.dark .bg-purple-400, +html.dark .bg-indigo-400 { + background-color: #93C5FD !important; +} + +html.dark .bg-violet-50, +html.dark .bg-violet-50\/40, +html.dark .bg-violet-50\/70, +html.dark .bg-violet-100, +html.dark .bg-violet-200, +html.dark .bg-purple-50, +html.dark .bg-purple-100, +html.dark .bg-indigo-100 { + background-color: rgba(129, 140, 248, 0.18) !important; +} + +html.dark .text-violet-500, +html.dark .text-violet-600, +html.dark .text-violet-700, +html.dark .text-purple-600, +html.dark .text-purple-700, +html.dark .text-indigo-600, +html.dark .text-indigo-700 { + color: #93C5FD !important; +} + +html.dark .bg-rose-50, +html.dark .bg-rose-100, +html.dark .bg-rose-500\/10, +html.dark .bg-red-50, +html.dark .bg-red-100 { + background-color: rgba(244, 63, 94, 0.18) !important; +} + +html.dark .text-rose-500, +html.dark .text-rose-600, +html.dark .text-rose-700, +html.dark .text-red-500, +html.dark .text-red-600, +html.dark .text-red-700 { + color: #FDA4AF !important; +} + +html.dark .bg-amber-50, +html.dark .bg-amber-100, +html.dark .bg-amber-500\/10, +html.dark .bg-yellow-50, +html.dark .bg-yellow-100, +html.dark .bg-orange-50, +html.dark .bg-orange-100 { + background-color: rgba(245, 158, 11, 0.18) !important; +} + +html.dark .text-amber-500, +html.dark .text-amber-600, +html.dark .text-amber-700, +html.dark .text-amber-800, +html.dark .text-yellow-600, +html.dark .text-yellow-700, +html.dark .text-orange-600, +html.dark .text-orange-700 { + color: #FCD34D !important; +} + +html.dark .bg-emerald-50, +html.dark .bg-emerald-100, +html.dark .bg-emerald-500\/10, +html.dark .bg-green-50, +html.dark .bg-green-100 { + background-color: rgba(16, 185, 129, 0.18) !important; +} + +html.dark .text-emerald-500, +html.dark .text-emerald-600, +html.dark .text-emerald-700, +html.dark .text-green-500, +html.dark .text-green-600, +html.dark .text-green-700 { + color: #6EE7B7 !important; +} + +html.dark .bg-blue-50, +html.dark .bg-blue-100, +html.dark .bg-sky-50, +html.dark .bg-cyan-50, +html.dark .bg-teal-50 { + background-color: rgba(14, 165, 233, 0.18) !important; +} + +html.dark .text-blue-600, +html.dark .text-blue-700, +html.dark .text-sky-600, +html.dark .text-cyan-600, +html.dark .text-teal-600 { + color: #7DD3FC !important; +} + +html.dark .hover\:bg-white:hover, +html.dark .hover\:bg-slate-50:hover, +html.dark .hover\:bg-slate-50\/50:hover, +html.dark .hover\:bg-slate-50\/60:hover, +html.dark .hover\:bg-slate-100:hover { + background-color: #1E293B !important; +} + +html.dark .hover\:bg-violet-500:hover, +html.dark .hover\:bg-violet-600:hover, +html.dark .hover\:bg-violet-700:hover, +html.dark .hover\:bg-purple-700:hover, +html.dark .hover\:bg-indigo-100:hover { + background-color: #4338CA !important; + color: #FFFFFF !important; +} + +html.dark .hover\:bg-violet-50:hover { + background-color: rgba(129, 140, 248, 0.22) !important; +} + +html.dark .hover\:bg-amber-50:hover { + background-color: rgba(245, 158, 11, 0.22) !important; +} + +html.dark .hover\:bg-rose-50:hover, +html.dark .hover\:bg-red-50:hover { + background-color: rgba(244, 63, 94, 0.22) !important; +} + +html.dark .hover\:text-slate-600:hover, +html.dark .hover\:text-slate-700:hover, +html.dark .hover\:text-slate-800:hover, +html.dark .hover\:text-slate-900:hover { + color: #F8FAFC !important; +} + +html.dark .hover\:text-violet-500:hover, +html.dark .hover\:text-violet-600:hover, +html.dark .hover\:text-violet-700:hover, +html.dark .hover\:text-violet-800:hover { + color: #93C5FD !important; +} + +html.dark .hover\:text-amber-600:hover { + color: #FCD34D !important; +} + +html.dark .hover\:text-rose-500:hover, +html.dark .hover\:text-rose-600:hover, +html.dark .hover\:text-red-600:hover { + color: #FDA4AF !important; +} + +html.dark .focus\:ring-slate-400:focus, +html.dark .focus\:ring-slate-900:focus, +html.dark .focus\:ring-violet-600:focus, +html.dark .focus\:ring-red-500:focus { + --tw-ring-color: #93C5FD !important; +} + +html.dark .shadow-sm { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35) !important; +} + +html.dark .shadow-lg, +html.dark .shadow-lg { + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28) !important; +} + +html.dark .flatpickr-calendar { + background: #111827; + border-color: #334155; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); +} + +html.dark .flatpickr-months, +html.dark .flatpickr-weekdays, +html.dark .flatpickr-current-month, +html.dark .flatpickr-weekday, +html.dark .flatpickr-day { + color: #E2E8F0; +} + +html.dark .flatpickr-day:hover, +html.dark .flatpickr-day:focus { + background: #1E293B; + border-color: #334155; +} + +html.dark .flatpickr-day.selected, +html.dark .flatpickr-day.startRange, +html.dark .flatpickr-day.endRange { + background: var(--brand-primary); + border-color: var(--brand-primary); + color: #FFFFFF; +} + +/* Taskify UI system */ +.app-shell { + --app-sidebar-width: 18rem; + display: flex; + min-height: 100vh; + background: var(--app-bg); + color: var(--text-primary); + overflow: hidden; +} + +.app-sidebar { + position: relative; + display: flex; + width: var(--app-sidebar-width); + min-height: 100vh; + flex-shrink: 0; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--sidebar-border); + background: var(--sidebar-bg); + color: #FFFFFF; +} + +.app-sidebar::before { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + background: transparent; +} + +.app-sidebar > * { + position: relative; + z-index: 1; +} + +.app-sidebar-brand { + display: flex; + min-height: 4.75rem; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; +} + +.app-sidebar-logo { + display: inline-flex; + height: 2.4rem; + width: 2.4rem; + align-items: center; + justify-content: center; + border-radius: var(--radius-lg); + background: var(--brand-primary); + color: #FFFFFF; + box-shadow: 0 10px 28px rgba(37, 99, 235, 0.28); +} + +.app-sidebar-title { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.app-sidebar-title strong { + font-size: 1.1rem; + letter-spacing: 0; +} + +.app-sidebar-title span { + margin-top: 0.15rem; + color: var(--sidebar-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.app-sidebar-user { + flex-shrink: 0; + border-top: 1px solid var(--sidebar-border); + padding: 0.875rem; +} + +.app-main { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.app-topbar { + display: flex; + min-height: 4rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 92%, transparent); + padding: 0 1.5rem; + backdrop-filter: blur(12px); +} + +.app-topbar-title { + min-width: 0; +} + +.app-topbar-title h1 { + margin: 0; + color: var(--text-primary); + font-size: 1.05rem; + font-weight: 750; + letter-spacing: 0; +} + +.app-topbar-title p { + margin: 0.1rem 0 0; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 500; +} + +.app-content { + flex: 1; + overflow-y: auto; + padding: clamp(1rem, 2vw, 2rem); +} + +@media (min-width: 960px) { + .app-shell { + height: 100dvh; + min-height: 100dvh; + padding-left: var(--app-sidebar-width); + } + + .app-shell .app-sidebar { + position: fixed; + inset: 0 auto 0 0; + height: 100dvh; + min-height: 100dvh; + transform: translateX(0) !important; + width: var(--app-sidebar-width) !important; + } + + .app-main { + height: 100dvh; + min-height: 100dvh; + } +} + +/* Tiered responsive scaling for laptops & smaller screens (e.g. 13.3" HP EliteBook G6) */ +@media (min-width: 1366px) and (max-width: 1536px) { + :root { + font-size: 13.75px; + } + .app-shell { + --app-sidebar-width: 15.75rem; + } + .app-content { + padding: 1.25rem !important; + } +} + +@media (min-width: 1150px) and (max-width: 1365px) { + :root { + font-size: 13px; + } + .app-shell { + --app-sidebar-width: 14.75rem; + } + .app-content { + padding: 1rem !important; + } + .app-topbar { + min-height: 3.5rem !important; + padding: 0 1.25rem !important; + } + .app-sidebar-brand { + min-height: 4rem !important; + padding: 0.75rem 1rem !important; + } +} + +/* Compact scaling for 13.3" displays with 150% scaling or 960px-1149px viewports */ +@media (min-width: 960px) and (max-width: 1149px) { + :root { + font-size: 12.25px; + } + .app-shell { + --app-sidebar-width: 13.75rem; + } + .app-content { + padding: 0.85rem !important; + } + .app-topbar { + min-height: 3.25rem !important; + padding: 0 1rem !important; + } + .app-topbar-title h1 { + font-size: 0.95rem !important; + } + .app-sidebar-brand { + min-height: 3.75rem !important; + padding: 0.65rem 0.85rem !important; + } + .app-sidebar-logo { + height: 2rem !important; + width: 2rem !important; + } + main td, main th { + padding-top: 0.45rem !important; + padding-bottom: 0.45rem !important; + } +} + +.mobile-sidebar-toggle, +.mobile-sidebar-close { + display: none; +} + +.sidebar-scrim { + display: none; +} + +.control-button { + display: inline-flex; + height: 2.35rem; + min-width: 2.35rem; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text-muted); + box-shadow: var(--shadow-card); + transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease; +} + +.control-button:hover { + border-color: var(--border-strong); + background: var(--surface-muted); + color: var(--brand-primary); +} + +.control-button:focus-visible { + outline: none; + box-shadow: 0 0 0 4px var(--focus-ring); +} + +.surface-panel { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-card); +} + +.page-kicker { + color: var(--brand-primary); + font-size: 0.74rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.text-balance { + text-wrap: balance; +} + +main :is(input, select, textarea, .form-control, .form-select) { + border-color: var(--border) !important; + border-radius: var(--radius-lg) !important; + background-color: var(--surface) !important; + color: var(--text-primary) !important; +} + +main :is(input, select, textarea, .form-control, .form-select):focus { + border-color: var(--brand-primary) !important; + outline: none !important; + box-shadow: 0 0 0 4px var(--focus-ring) !important; +} + +main :is(input, textarea)::placeholder { + color: var(--text-soft) !important; +} + +main table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +main thead tr { + background: var(--surface-muted) !important; +} + +main th { + color: var(--text-muted) !important; + white-space: nowrap; +} + +main td { + vertical-align: middle; +} + +main tbody tr { + transition: background-color 0.14s ease; +} + +main tbody tr:hover { + background: var(--surface-muted) !important; +} + +main .shadow-\[0_18px_50px_rgba\(15\,23\,42\,0\.08\)\], +main .shadow-\[0_18px_50px_rgba\(15\,23\,42\,0\.12\)\] { + box-shadow: var(--shadow-card) !important; +} + +main .rounded-\[32px\], +main .rounded-\[28px\], +main .rounded-\[24px\] { + border-radius: var(--radius-lg) !important; +} + +.flatpickr-calendar { + border-color: var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-popover); +} + +html.dark a, +html.dark .btn-link, +html.dark .text-violet-500, +html.dark .text-violet-600, +html.dark .text-violet-700, +html.dark .text-purple-600, +html.dark .text-purple-700, +html.dark .text-indigo-600, +html.dark .text-indigo-700 { + color: var(--brand-primary) !important; +} + +html.dark .bg-white, +html.dark .bg-card, +html.dark .bg-popover { + background-color: var(--surface) !important; +} + +html.dark .bg-slate-50, +html.dark .bg-slate-50\/40, +html.dark .bg-slate-50\/50, +html.dark .bg-slate-50\/60, +html.dark .bg-slate-50\/70, +html.dark .bg-muted { + background-color: var(--surface-muted) !important; +} + +html.dark .border-slate-100, +html.dark .border-slate-200, +html.dark .border-slate-300, +html.dark .border-border, +html.dark .divide-slate-100, +html.dark .divide-slate-200 { + border-color: var(--border) !important; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-foreground, +html.dark .text-card-foreground, +html.dark .text-popover-foreground { + color: var(--text-primary) !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-muted, +html.dark .text-muted-foreground { + color: var(--text-muted) !important; +} + +html.dark .text-slate-400, +html.dark .text-slate-300 { + color: var(--text-soft) !important; +} + +html.dark .btn-primary, +html.dark .bg-primary, +html.dark .bg-violet-500, +html.dark .bg-violet-600, +html.dark .bg-violet-700, +html.dark .bg-purple-500, +html.dark .bg-purple-600, +html.dark .bg-indigo-500, +html.dark .bg-indigo-600 { + background-color: var(--brand-primary) !important; + border-color: var(--brand-primary) !important; + color: #FFFFFF !important; +} + +html.dark .hover\:bg-violet-600:hover, +html.dark .hover\:bg-violet-700:hover, +html.dark .hover\:bg-purple-700:hover, +html.dark .hover\:bg-indigo-100:hover { + background-color: var(--brand-primary-hover) !important; + color: #FFFFFF !important; +} + +@media (max-width: 959px) { + .app-shell { + display: block; + min-height: 100vh; + overflow: auto; + padding-left: 0 !important; + } + + .app-sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 60; + width: min(18rem, calc(100vw - 3rem)) !important; + transform: translateX(-100%); + transition: transform 0.22s ease; + } + + body.tts-sidebar-open .app-sidebar { + transform: translateX(0) !important; + } + + .sidebar-scrim { + position: fixed; + inset: 0; + z-index: 50; + display: none; + border: 0; + background: rgba(15, 23, 42, 0.48); + } + + body.tts-sidebar-open .sidebar-scrim { + display: block; + } + + .mobile-sidebar-toggle, + .mobile-sidebar-close { + display: inline-flex !important; + } + + .app-main { + min-height: 100vh; + } + + .app-topbar { + min-height: 3.5rem; + padding: 0 1rem; + } + + .app-content { + padding: 1rem !important; + } + + .app-topbar-title p { + display: none; + } +} + +@media (max-width: 640px) { + html, + body { + font-size: 14px; + } + + .app-content { + padding: 0.875rem; + } + + main table { + min-width: 760px; + } +} + +/* ========================================== + UNIVERSAL DARK MODE OVERRIDES + ========================================== */ +html.dark, +html.dark body, +html.dark .app-shell, +html.dark .app-main, +html.dark .app-content { + background-color: #0B1120 !important; + color: #F8FAFC !important; +} + +html.dark .bg-white, +html.dark .bg-card, +html.dark .bg-popover, +html.dark [class*="bg-white"] { + background-color: #111827 !important; + color: #F8FAFC !important; +} + +html.dark .bg-slate-50, +html.dark .bg-slate-50\/40, +html.dark .bg-slate-50\/50, +html.dark .bg-slate-50\/60, +html.dark .bg-slate-50\/70, +html.dark .bg-slate-50\/95, +html.dark .bg-slate-100, +html.dark .bg-slate-100\/50, +html.dark .bg-muted { + background-color: #182235 !important; + color: #F8FAFC !important; +} + +html.dark .bg-slate-200, +html.dark .bg-slate-300 { + background-color: #263449 !important; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-foreground, +html.dark .text-card-foreground, +html.dark .text-popover-foreground { + color: #F8FAFC !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-muted, +html.dark .text-muted-foreground { + color: #CBD5E1 !important; +} + +html.dark .text-slate-400, +html.dark .text-slate-300 { + color: #94A3B8 !important; +} + +html.dark .border-slate-100, +html.dark .border-slate-200, +html.dark .border-slate-300, +html.dark .border-border, +html.dark .divide-slate-100, +html.dark .divide-slate-200 { + border-color: #263449 !important; +} + +html.dark .shadow-slate-200\/60, +html.dark .shadow-slate-200\/50, +html.dark .shadow-slate-200\/70, +html.dark .shadow-sm, +html.dark .shadow-md, +html.dark .shadow-lg { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45) !important; +} + +html.dark svg line[stroke="#e2e8f0"], +html.dark svg line[stroke="#E2E8F0"] { + stroke: #263449 !important; +} + +html.dark input, +html.dark select, +html.dark textarea, +html.dark .form-control, +html.dark .form-select { + background-color: #111827 !important; + color: #F8FAFC !important; + border-color: #263449 !important; +} + +/* ========================================== + PROFESSIONAL UI REFRESH + ========================================== */ +body { + background: var(--app-bg); +} + +::selection { + background: rgba(15, 118, 110, 0.18); + color: var(--text-primary); +} + +.app-shell { + background: var(--app-bg); +} + +.app-shell .app-sidebar { + background: var(--sidebar-bg); +} + +.app-sidebar::before { + background: transparent; +} + +.app-sidebar-logo { + background: #0F766E; + box-shadow: 0 12px 28px rgba(15, 118, 110, 0.24); +} + +.app-topbar { + background: color-mix(in srgb, var(--surface) 94%, transparent); +} + +.app-content { + background: transparent; +} + +.control-button, +main button, +main a[class*="inline-flex"], +main .btn { + border-radius: var(--radius-lg) !important; +} + +.control-button { + background: color-mix(in srgb, var(--surface) 96%, var(--brand-primary) 4%); +} + +.btn-primary, +main .bg-violet-500, +main .bg-violet-600, +main .bg-violet-700, +main .bg-purple-500, +main .bg-purple-600, +main .bg-indigo-500, +main .bg-indigo-600, +main .bg-blue-600 { + background-color: #0F766E !important; + border-color: #0F766E !important; + color: #FFFFFF !important; +} + +.btn-primary:hover, +main .hover\:bg-violet-500:hover, +main .hover\:bg-violet-600:hover, +main .hover\:bg-violet-700:hover, +main .hover\:bg-purple-700:hover, +main .hover\:bg-blue-600:hover, +main .hover\:bg-blue-700:hover { + background-color: #115E59 !important; + border-color: #115E59 !important; + color: #FFFFFF !important; +} + +main .text-violet-500, +main .text-violet-600, +main .text-violet-700, +main .text-purple-600, +main .text-purple-700, +main .text-indigo-600, +main .text-indigo-700, +main .text-blue-600, +main .text-blue-700 { + color: #0F766E !important; +} + +main .hover\:text-violet-500:hover, +main .hover\:text-violet-600:hover, +main .hover\:text-violet-700:hover, +main .hover\:text-violet-800:hover, +main .hover\:text-blue-600:hover, +main .hover\:text-blue-700:hover { + color: #115E59 !important; +} + +main .bg-violet-50, +main .bg-violet-50\/10, +main .bg-violet-50\/40, +main .bg-violet-50\/60, +main .bg-violet-50\/70, +main .bg-violet-100, +main .bg-purple-50, +main .bg-purple-100, +main .bg-indigo-50, +main .bg-indigo-100, +main .bg-blue-50, +main .bg-blue-100 { + background-color: #E6F4F1 !important; +} + +main .border-violet-100, +main .border-violet-200, +main .border-violet-300, +main .border-indigo-100, +main .border-indigo-200, +main .border-blue-100, +main .border-blue-200 { + border-color: #B7DAD4 !important; +} + +main .focus\:ring-violet-100:focus, +main .focus\:ring-violet-500:focus, +main .focus\:ring-violet-600:focus, +main .focus\:ring-slate-900:focus { + --tw-ring-color: var(--focus-ring) !important; +} + +main .focus\:border-violet-400:focus, +main .focus\:border-violet-600:focus { + border-color: var(--brand-primary) !important; +} + +main .accent-violet-600, +html.dark main .accent-violet-600 { + accent-color: #0F766E !important; +} + +main .bg-white, +main .bg-card, +main .bg-popover { + background-color: var(--surface) !important; + color: var(--text-primary) !important; +} + +main .bg-slate-50, +main .bg-slate-50\/40, +main .bg-slate-50\/50, +main .bg-slate-50\/60, +main .bg-slate-50\/70, +main .bg-slate-50\/95, +main .bg-muted { + background-color: var(--surface-muted) !important; +} + +main .border-slate-100, +main .border-slate-200, +main .border-slate-300, +main .border-border, +main .divide-slate-100, +main .divide-slate-200 { + border-color: var(--border) !important; +} + +main .text-slate-950, +main .text-slate-900, +main .text-slate-800, +main .text-slate-700, +main .text-foreground, +main .text-card-foreground, +main .text-popover-foreground { + color: var(--text-primary) !important; +} + +main .text-slate-600, +main .text-slate-500, +main .text-muted, +main .text-muted-foreground { + color: var(--text-muted) !important; +} + +main .text-slate-400, +main .text-slate-300 { + color: var(--text-soft) !important; +} + +main .shadow-sm, +main .shadow-md, +main .shadow-lg, +main .shadow-slate-200\/50, +main .shadow-slate-200\/60, +main .shadow-slate-200\/70 { + box-shadow: var(--shadow-card) !important; +} + +main :is(.rounded-xl, .rounded-2xl, .rounded-3xl, .rounded-\[24px\], .rounded-\[28px\], .rounded-\[32px\]) { + border-radius: var(--radius-lg) !important; +} + +main :is(table, .surface-panel), +main :is(.border-slate-200.bg-white, .border-slate-100.bg-white) { + box-shadow: var(--shadow-card); +} + +main thead tr { + background: color-mix(in srgb, var(--surface-muted) 88%, var(--brand-primary) 12%) !important; +} + +main th { + font-size: 0.72rem !important; + letter-spacing: 0.06em !important; +} + +main tbody tr:hover { + background: color-mix(in srgb, var(--surface-muted) 84%, var(--brand-primary) 16%) !important; +} + +main :is(input, select, textarea, .form-control, .form-select) { + min-height: 2.45rem; +} + +main label { + color: var(--text-muted) !important; + font-weight: 650; +} + +main [class*="bg-emerald-100"], +main [class*="bg-green-100"] { + background-color: var(--status-success-bg) !important; + color: var(--status-success-text) !important; +} + +main [class*="bg-amber-100"], +main [class*="bg-yellow-100"], +main [class*="bg-orange-100"] { + background-color: var(--status-warning-bg) !important; + color: var(--status-warning-text) !important; +} + +main [class*="bg-red-100"], +main [class*="bg-rose-100"] { + background-color: var(--status-danger-bg) !important; + color: var(--status-danger-text) !important; +} + +main svg line[stroke="#e2e8f0"], +main svg line[stroke="#E2E8F0"] { + stroke: var(--border) !important; +} + +main svg path[stroke="#2563eb"], +main svg path[stroke="#3b82f6"] { + stroke: var(--brand-primary) !important; +} + +main svg circle[fill="#2563eb"], +main svg circle[fill="#3b82f6"] { + fill: var(--brand-primary) !important; +} + +html.dark, +html.dark body, +html.dark .app-shell, +html.dark .app-main, +html.dark .app-content { + background-color: var(--app-bg) !important; + color: var(--text-primary) !important; +} + +html.dark body { + background: var(--app-bg); +} + +html.dark .app-shell { + background: var(--app-bg); +} + +html.dark main .bg-white, +html.dark main .bg-card, +html.dark main .bg-popover, +html.dark main [class*="bg-white"] { + background-color: var(--surface) !important; + color: var(--text-primary) !important; +} + +html.dark main .bg-slate-50, +html.dark main .bg-slate-50\/40, +html.dark main .bg-slate-50\/50, +html.dark main .bg-slate-50\/60, +html.dark main .bg-slate-50\/70, +html.dark main .bg-slate-50\/95, +html.dark main .bg-slate-100, +html.dark main .bg-slate-100\/50, +html.dark main .bg-muted { + background-color: var(--surface-muted) !important; + color: var(--text-primary) !important; +} + +html.dark main .bg-slate-200, +html.dark main .bg-slate-300 { + background-color: #2E3B37 !important; +} + +html.dark main .text-slate-950, +html.dark main .text-slate-900, +html.dark main .text-slate-800, +html.dark main .text-slate-700, +html.dark main .text-foreground, +html.dark main .text-card-foreground, +html.dark main .text-popover-foreground { + color: var(--text-primary) !important; +} + +html.dark main .text-slate-600, +html.dark main .text-slate-500, +html.dark main .text-muted, +html.dark main .text-muted-foreground { + color: var(--text-muted) !important; +} + +html.dark main .text-slate-400, +html.dark main .text-slate-300 { + color: var(--text-soft) !important; +} + +html.dark main .border-slate-100, +html.dark main .border-slate-200, +html.dark main .border-slate-300, +html.dark main .border-border, +html.dark main .divide-slate-100, +html.dark main .divide-slate-200 { + border-color: var(--border) !important; +} + +html.dark main .bg-violet-50, +html.dark main .bg-violet-50\/10, +html.dark main .bg-violet-50\/40, +html.dark main .bg-violet-50\/60, +html.dark main .bg-violet-50\/70, +html.dark main .bg-violet-100, +html.dark main .bg-purple-50, +html.dark main .bg-purple-100, +html.dark main .bg-indigo-50, +html.dark main .bg-indigo-100, +html.dark main .bg-blue-50, +html.dark main .bg-blue-100 { + background-color: rgba(94, 234, 212, 0.13) !important; +} + +html.dark main .text-violet-500, +html.dark main .text-violet-600, +html.dark main .text-violet-700, +html.dark main .text-purple-600, +html.dark main .text-purple-700, +html.dark main .text-indigo-600, +html.dark main .text-indigo-700, +html.dark main .text-blue-600, +html.dark main .text-blue-700 { + color: var(--brand-primary) !important; +} + +html.dark main .btn-primary, +html.dark main .bg-primary, +html.dark main .bg-violet-500, +html.dark main .bg-violet-600, +html.dark main .bg-violet-700, +html.dark main .bg-purple-500, +html.dark main .bg-purple-600, +html.dark main .bg-indigo-500, +html.dark main .bg-indigo-600, +html.dark main .bg-blue-600 { + background-color: #0F766E !important; + border-color: #0F766E !important; + color: #FFFFFF !important; +} + +html.dark input, +html.dark select, +html.dark textarea, +html.dark .form-control, +html.dark .form-select { + background-color: var(--surface-raised) !important; + color: var(--text-primary) !important; + border-color: var(--border) !important; +} + +/* ========================================== + SHELL + DASHBOARD QUALITY GUARD + Loaded after Tailwind so layout-critical rules always win. + ========================================== */ +:root { + --app-sidebar-width: 16.25rem; + --chart-primary: #0F766E; + --chart-secondary: #38BDF8; + --chart-success: #10B981; + --chart-warning: #B45309; + --chart-danger: #DC2626; +} + +html, +body { + width: 100%; + min-width: 0; + overflow-x: hidden; +} + +body.tts-sidebar-open { + overflow: auto; +} + +.app-shell { + display: flex !important; + gap: 1rem !important; + min-width: 0 !important; + width: 100% !important; + background: var(--app-bg) !important; +} + +.app-shell .app-sidebar { + position: sticky !important; + inset: 0 auto auto 0 !important; + z-index: 40 !important; + width: var(--app-sidebar-width) !important; + height: 100dvh !important; + min-height: 100dvh !important; + max-width: var(--app-sidebar-width) !important; + transform: translateX(0) !important; + background: var(--sidebar-bg) !important; +} + +.app-main { + min-width: 0 !important; + width: 100% !important; + flex: 1 1 auto !important; + margin-left: 1rem !important; +} + +.app-topbar { + position: sticky; + top: 0; + z-index: 30; + min-height: 4.1rem !important; + background: color-mix(in srgb, var(--surface) 96%, transparent) !important; +} + +.app-topbar > div:last-child { + position: relative; + z-index: 45; + pointer-events: auto; +} + +.app-topbar .control-button, +.app-topbar [data-theme-toggle] { + position: relative; + z-index: 46; + cursor: pointer; + pointer-events: auto !important; +} + +.app-content { + width: 100% !important; + min-width: 0 !important; + padding: clamp(1.25rem, 1.8vw, 1.75rem) !important; +} + +.mobile-sidebar-toggle, +.mobile-sidebar-close, +.sidebar-scrim { + display: none !important; +} + +main > .space-y-8, +main > .space-y-6 { + max-width: 96rem; + margin-right: auto; + margin-left: auto; +} + +main :is(.grid) { + min-width: 0; +} + +main :is(.rounded-xl.border, .rounded-lg.border, .surface-panel) { + border-color: var(--border) !important; +} + +main :is(.rounded-xl.border.border-slate-200.bg-white, .rounded-lg.border.border-slate-200.bg-white) { + background: var(--surface) !important; +} + +main :is(h2, h3) { + letter-spacing: 0 !important; +} + +main .page-kicker, +main .uppercase.tracking-\[0\.08em\], +main .uppercase.tracking-wider { + color: var(--brand-primary) !important; +} + +main a, +main button { + touch-action: manipulation; +} + +main button, +main a[class*="inline-flex"] { + min-height: 2.75rem; +} + +main svg [stroke="#2563eb"], +main svg [stroke="#3b82f6"], +main svg [stroke="#06b6d4"] { + stroke: var(--chart-primary) !important; +} + +main svg [fill="#2563eb"], +main svg [fill="#3b82f6"], +main svg [fill="#06b6d4"] { + fill: var(--chart-primary) !important; +} + +main svg stop[stop-color="#2563eb"], +main svg stop[stop-color="#3b82f6"] { + stop-color: var(--chart-primary) !important; +} + +main svg [stroke="#ef4444"], +main svg [fill="#ef4444"] { + stroke: var(--chart-danger) !important; + fill: var(--chart-danger) !important; +} + +@media (min-width: 960px) { + body.tts-sidebar-open { + overflow: auto !important; + } + + .app-shell { + height: 100dvh !important; + min-height: 100dvh !important; + padding-left: 0 !important; + overflow: hidden !important; + gap: 1rem !important; + } + + .app-main { + height: 100dvh !important; + min-height: 100dvh !important; + } + + .app-content { + overflow-y: auto !important; + } +} + +@media (min-width: 1366px) and (max-width: 1536px) { + :root { + --app-sidebar-width: 15rem; + } +} + +@media (min-width: 1150px) and (max-width: 1365px) { + :root { + --app-sidebar-width: 14.25rem; + } +} + +@media (min-width: 960px) and (max-width: 1149px) { + :root { + --app-sidebar-width: 13.25rem; + } +} + +@media (max-width: 959px) { + body.tts-sidebar-open { + overflow: hidden !important; + } + + .app-shell { + display: block !important; + min-height: 100dvh !important; + padding-left: 0 !important; + overflow: auto !important; + gap: 0 !important; + } + + .app-sidebar { + width: min(18rem, calc(100vw - 3rem)) !important; + transform: translateX(-100%) !important; + transition: transform 0.22s ease !important; + } + + body.tts-sidebar-open .app-sidebar { + transform: translateX(0) !important; + } + + .sidebar-scrim { + position: fixed !important; + inset: 0 !important; + z-index: 50 !important; + display: none !important; + border: 0 !important; + background: rgba(16, 22, 20, 0.54) !important; + } + + body.tts-sidebar-open .sidebar-scrim { + display: block !important; + } + + .mobile-sidebar-toggle, + .mobile-sidebar-close { + display: inline-flex !important; + } + + .app-main { + min-height: 100dvh !important; + margin-left: 0 !important; + } + + .app-topbar { + min-height: 3.6rem !important; + } +} + +/* Light-mode navigation: keep the workspace bright while preserving the teal identity. */ +html:not(.dark) { + --sidebar-bg: #EAF4F1; + --sidebar-muted: #65746F; + --sidebar-active: #D4ECE6; + --sidebar-border: #C8DCD6; +} + +html:not(.dark) .app-shell .app-sidebar { + background: + linear-gradient(180deg, #F4FAF8 0%, #E7F2EF 52%, #DDEBE7 100%) !important; + border-right-color: var(--sidebar-border) !important; + color: var(--text-primary) !important; + box-shadow: 1px 0 0 rgba(15, 118, 110, 0.04); +} + +html:not(.dark) .app-sidebar-title strong { + color: #13211E !important; +} + +html:not(.dark) .app-sidebar-title span { + color: #58706A !important; +} + +html:not(.dark) .app-sidebar-logo { + background: #0F766E !important; + color: #FFFFFF !important; + box-shadow: 0 12px 28px rgba(15, 118, 110, 0.22); +} + +html:not(.dark) .app-sidebar :is(.nav-link-item, .nav-dropdown-toggle) { + color: #40514C !important; +} + +html:not(.dark) .app-sidebar :is(.nav-link-item, .nav-dropdown-toggle) i { + color: #65746F !important; +} + +html:not(.dark) .app-sidebar :is(.nav-link-item:hover, .nav-dropdown-toggle:hover, .nav-dropdown-toggle.expanded) { + background: rgba(15, 118, 110, 0.09) !important; + border-color: rgba(15, 118, 110, 0.16) !important; + color: #0F4F49 !important; +} + +html:not(.dark) .app-sidebar .nav-link-item.active { + background: var(--sidebar-active) !important; + border-color: rgba(15, 118, 110, 0.28) !important; + color: #0B3F3A !important; + box-shadow: inset 3px 0 0 #0F766E, 0 8px 18px rgba(15, 118, 110, 0.08) !important; +} + +html:not(.dark) .app-sidebar .nav-link-item.active i, +html:not(.dark) .app-sidebar :is(.nav-link-item:hover, .nav-dropdown-toggle:hover, .nav-dropdown-toggle.expanded) i { + color: #0F766E !important; +} + +html:not(.dark) .app-sidebar .nav-dropdown-children { + border-left-color: rgba(15, 118, 110, 0.18) !important; +} + +html:not(.dark) .app-sidebar-user { + border-top-color: var(--sidebar-border) !important; + background: rgba(244, 250, 248, 0.92) !important; +} + +html:not(.dark) .app-sidebar-user > div, +html:not(.dark) .app-sidebar-user a { + border-color: rgba(15, 118, 110, 0.16) !important; + background: rgba(255, 255, 255, 0.74) !important; + color: #17211F !important; +} + +html:not(.dark) .app-sidebar-user span { + color: #17211F !important; +} + +html:not(.dark) .app-sidebar-user .rounded-full { + background: #D8E9E5 !important; + color: #0F4F49 !important; +} + +html:not(.dark) .app-sidebar-user button { + color: #52635E !important; +} + +html:not(.dark) .mobile-sidebar-close { + border-color: rgba(15, 118, 110, 0.16) !important; + background: rgba(255, 255, 255, 0.72) !important; + color: #0F4F49 !important; +} diff --git a/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css b/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css new file mode 100644 index 0000000000000000000000000000000000000000..02ae65b5fe5d12c881eb054006f68b52a2818615 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.0 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css.map b/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css.map new file mode 100644 index 0000000000000000000000000000000000000000..afcd9e33e9eae3876cdc0dfaafa5a80e012b1631 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBAAA;;;;;ACAA,MAQI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EACA,cAAA,EAAA,CAAA,EAAA,CAAA,GAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAQA,sBAAA,0BACA,oBAAA,KACA,sBAAA,IACA,sBAAA,IACA,gBAAA,QAIA,aAAA,KClCF,EC+CA,QADA,SD3CE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BEmPI,UAAA,yBFjPJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YAUF,GACE,OAAA,KAAA,EACA,MAAA,QACA,iBAAA,aACA,OAAA,EACA,QAAA,IAGF,eACE,OAAA,IAUF,IAAA,IAAA,IAAA,IAAA,IAAA,IAAA,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IAIF,IAAA,GEwMQ,UAAA,uBAlKJ,0BFtCJ,IAAA,GE+MQ,UAAA,QF1MR,IAAA,GEmMQ,UAAA,sBAlKJ,0BFjCJ,IAAA,GE0MQ,UAAA,MFrMR,IAAA,GE8LQ,UAAA,oBAlKJ,0BF5BJ,IAAA,GEqMQ,UAAA,SFhMR,IAAA,GEyLQ,UAAA,sBAlKJ,0BFvBJ,IAAA,GEgMQ,UAAA,QF3LR,IAAA,GEgLM,UAAA,QF3KN,IAAA,GE2KM,UAAA,KFhKN,EACE,WAAA,EACA,cAAA,KCmBF,6BDRA,YAEE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GCIA,GDFE,aAAA,KCQF,GDLA,GCIA,GDDE,WAAA,EACA,cAAA,KAGF,MCKA,MACA,MAFA,MDAE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,ECNA,ODQE,YAAA,OAQF,OAAA,ME4EM,UAAA,OFrEN,MAAA,KACE,QAAA,KACA,iBAAA,QASF,ICpBA,IDsBE,SAAA,SEwDI,UAAA,MFtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,QACA,gBAAA,UAEA,QACE,MAAA,QAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KCxBJ,KACA,ID8BA,IC7BA,KDiCE,YAAA,yBEcI,UAAA,IFZJ,UAAA,IACA,aAAA,cAOF,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KEAI,UAAA,OFKJ,SELI,UAAA,QFOF,MAAA,QACA,WAAA,OAIJ,KEZM,UAAA,OFcJ,MAAA,QACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,MAAA,MExBI,UAAA,OF0BJ,MAAA,KACA,iBAAA,QG7SE,cAAA,MHgTF,QACE,QAAA,EE/BE,UAAA,IFiCF,YAAA,IASJ,OACE,OAAA,EAAA,EAAA,KAMF,ICjDA,IDmDE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,QACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBCxDF,MAGA,GAFA,MAGA,GDuDA,MCzDA,GD+DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,ECtEF,OD2EA,MCzEA,SADA,OAEA,SD6EE,OAAA,EACA,YAAA,QE9HI,UAAA,QFgIJ,YAAA,QAIF,OC5EA,OD8EE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0CACE,QAAA,KClFF,cACA,aACA,cDwFA,OAIE,mBAAA,OCxFF,6BACA,4BACA,6BDyFI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,KACA,MAAA,KACA,QAAA,EACA,cAAA,MEnNM,UAAA,sBFsNN,YAAA,QExXE,0BFiXJ,OExMQ,UAAA,QFiNN,SACE,MAAA,KChGJ,kCDuGA,uCCxGA,mCADA,+BAGA,oCAJA,6BAKA,mCD4GE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,eAAA,KACA,mBAAA,UAmBF,4BACE,mBAAA,KAKF,+BACE,QAAA,EAMF,uBACE,KAAA,QAMF,6BACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA,eInlBF,MFyQM,UAAA,QEvQJ,YAAA,IAKA,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QE7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,ME7QN,WFsQM,UAAA,uBEpQJ,YAAA,IACA,YAAA,IFiGA,0BEpGF,WF6QM,UAAA,QEvPR,eCrDE,aAAA,EACA,WAAA,KDyDF,aC1DE,aAAA,EACA,WAAA,KD4DF,kBACE,QAAA,aAEA,mCACE,aAAA,MAUJ,YFsNM,UAAA,OEpNJ,eAAA,UAIF,YACE,cAAA,KF+MI,UAAA,QE5MJ,wBACE,cAAA,EAIJ,mBACE,WAAA,MACA,cAAA,KFqMI,UAAA,OEnMJ,MAAA,QAEA,2BACE,QAAA,KE9FJ,WCIE,UAAA,KAGA,OAAA,KDDF,eACE,QAAA,OACA,iBAAA,KACA,OAAA,IAAA,MAAA,QHGE,cAAA,OIRF,UAAA,KAGA,OAAA,KDcF,QAEE,QAAA,aAGF,YACE,cAAA,MACA,YAAA,EAGF,gBJ+PM,UAAA,OI7PJ,MAAA,QElCA,WPqmBF,iBAGA,cACA,cACA,cAHA,cADA,eQzmBE,MAAA,KACA,cAAA,0BACA,aAAA,0BACA,aAAA,KACA,YAAA,KCwDE,yBF5CE,WAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cACE,UAAA,OE2CJ,yBF5CE,WAAA,cAAA,cAAA,cACE,UAAA,OE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cACE,UAAA,QE2CJ,0BF5CE,WAAA,cAAA,cAAA,cAAA,cAAA,eACE,UAAA,QGfN,KCAA,cAAA,OACA,cAAA,EACA,QAAA,KACA,UAAA,KACA,WAAA,8BACA,aAAA,+BACA,YAAA,+BDHE,OCYF,YAAA,EACA,MAAA,KACA,UAAA,KACA,cAAA,8BACA,aAAA,8BACA,WAAA,mBA+CI,KACE,KAAA,EAAA,EAAA,GAGF,iBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,cACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,UAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,UAxDV,YAAA,YAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,WAxDV,YAAA,aAwDU,WAxDV,YAAA,aAmEM,KXusBR,MWrsBU,cAAA,EAGF,KXusBR,MWrsBU,cAAA,EAPF,KXitBR,MW/sBU,cAAA,QAGF,KXitBR,MW/sBU,cAAA,QAPF,KX2tBR,MWztBU,cAAA,OAGF,KX2tBR,MWztBU,cAAA,OAPF,KXquBR,MWnuBU,cAAA,KAGF,KXquBR,MWnuBU,cAAA,KAPF,KX+uBR,MW7uBU,cAAA,OAGF,KX+uBR,MW7uBU,cAAA,OAPF,KXyvBR,MWvvBU,cAAA,KAGF,KXyvBR,MWvvBU,cAAA,KFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX45BR,SW15BU,cAAA,EAGF,QX45BR,SW15BU,cAAA,EAPF,QXs6BR,SWp6BU,cAAA,QAGF,QXs6BR,SWp6BU,cAAA,QAPF,QXg7BR,SW96BU,cAAA,OAGF,QXg7BR,SW96BU,cAAA,OAPF,QX07BR,SWx7BU,cAAA,KAGF,QX07BR,SWx7BU,cAAA,KAPF,QXo8BR,SWl8BU,cAAA,OAGF,QXo8BR,SWl8BU,cAAA,OAPF,QX88BR,SW58BU,cAAA,KAGF,QX88BR,SW58BU,cAAA,MFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXinCR,SW/mCU,cAAA,EAGF,QXinCR,SW/mCU,cAAA,EAPF,QX2nCR,SWznCU,cAAA,QAGF,QX2nCR,SWznCU,cAAA,QAPF,QXqoCR,SWnoCU,cAAA,OAGF,QXqoCR,SWnoCU,cAAA,OAPF,QX+oCR,SW7oCU,cAAA,KAGF,QX+oCR,SW7oCU,cAAA,KAPF,QXypCR,SWvpCU,cAAA,OAGF,QXypCR,SWvpCU,cAAA,OAPF,QXmqCR,SWjqCU,cAAA,KAGF,QXmqCR,SWjqCU,cAAA,MFzDN,yBESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QXs0CR,SWp0CU,cAAA,EAGF,QXs0CR,SWp0CU,cAAA,EAPF,QXg1CR,SW90CU,cAAA,QAGF,QXg1CR,SW90CU,cAAA,QAPF,QX01CR,SWx1CU,cAAA,OAGF,QX01CR,SWx1CU,cAAA,OAPF,QXo2CR,SWl2CU,cAAA,KAGF,QXo2CR,SWl2CU,cAAA,KAPF,QX82CR,SW52CU,cAAA,OAGF,QX82CR,SW52CU,cAAA,OAPF,QXw3CR,SWt3CU,cAAA,KAGF,QXw3CR,SWt3CU,cAAA,MFzDN,0BESE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QX2hDR,SWzhDU,cAAA,EAGF,QX2hDR,SWzhDU,cAAA,EAPF,QXqiDR,SWniDU,cAAA,QAGF,QXqiDR,SWniDU,cAAA,QAPF,QX+iDR,SW7iDU,cAAA,OAGF,QX+iDR,SW7iDU,cAAA,OAPF,QXyjDR,SWvjDU,cAAA,KAGF,QXyjDR,SWvjDU,cAAA,KAPF,QXmkDR,SWjkDU,cAAA,OAGF,QXmkDR,SWjkDU,cAAA,OAPF,QX6kDR,SW3kDU,cAAA,KAGF,QX6kDR,SW3kDU,cAAA,MFzDN,0BESE,SACE,KAAA,EAAA,EAAA,GAGF,qBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,eA+BE,cAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,cAxDV,YAAA,EAwDU,cAxDV,YAAA,YAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,eAxDV,YAAA,aAwDU,eAxDV,YAAA,aAmEM,SXgvDR,UW9uDU,cAAA,EAGF,SXgvDR,UW9uDU,cAAA,EAPF,SX0vDR,UWxvDU,cAAA,QAGF,SX0vDR,UWxvDU,cAAA,QAPF,SXowDR,UWlwDU,cAAA,OAGF,SXowDR,UWlwDU,cAAA,OAPF,SX8wDR,UW5wDU,cAAA,KAGF,SX8wDR,UW5wDU,cAAA,KAPF,SXwxDR,UWtxDU,cAAA,OAGF,SXwxDR,UWtxDU,cAAA,OAPF,SXkyDR,UWhyDU,cAAA,KAGF,SXkyDR,UWhyDU,cAAA,MCpHV,OACE,cAAA,YACA,qBAAA,YACA,yBAAA,QACA,sBAAA,oBACA,wBAAA,QACA,qBAAA,mBACA,uBAAA,QACA,oBAAA,qBAEA,MAAA,KACA,cAAA,KACA,MAAA,QACA,eAAA,IACA,aAAA,QAOA,yBACE,QAAA,MAAA,MACA,iBAAA,mBACA,oBAAA,IACA,WAAA,MAAA,EAAA,EAAA,EAAA,OAAA,0BAGF,aACE,eAAA,QAGF,aACE,eAAA,OAIF,uCACE,oBAAA,aASJ,aACE,aAAA,IAUA,4BACE,QAAA,OAAA,OAeF,gCACE,aAAA,IAAA,EAGA,kCACE,aAAA,EAAA,IAOJ,oCACE,oBAAA,EASF,yCACE,qBAAA,2BACA,MAAA,8BAQJ,cACE,qBAAA,0BACA,MAAA,6BAQA,4BACE,qBAAA,yBACA,MAAA,4BCxHF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,iBAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,eAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,cAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,aAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QAfF,YAME,cAAA,QACA,sBAAA,QACA,yBAAA,KACA,qBAAA,QACA,wBAAA,KACA,oBAAA,QACA,uBAAA,KAEA,MAAA,KACA,aAAA,QDgIA,kBACE,WAAA,KACA,2BAAA,MHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,4BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,6BGqEA,qBACE,WAAA,KACA,2BAAA,OHvEF,6BGqEA,sBACE,WAAA,KACA,2BAAA,OE/IN,YACE,cAAA,MASF,gBACE,YAAA,oBACA,eAAA,oBACA,cAAA,EboRI,UAAA,QahRJ,YAAA,IAIF,mBACE,YAAA,kBACA,eAAA,kBb0QI,UAAA,QatQN,mBACE,YAAA,mBACA,eAAA,mBboQI,UAAA,QcjSN,WACE,WAAA,OdgSI,UAAA,Oc5RJ,MAAA,QCLF,cACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,Of8RI,UAAA,Ke3RJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,QACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KdGE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDhBN,cCiBQ,WAAA,MDGN,yBACE,SAAA,OAEA,wDACE,OAAA,QAKJ,oBACE,MAAA,QACA,iBAAA,KACA,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAOJ,2CAEE,OAAA,MAIF,gCACE,MAAA,QAEA,QAAA,EAHF,2BACE,MAAA,QAEA,QAAA,EAQF,uBAAA,wBAEE,iBAAA,QAGA,QAAA,EAIF,oCACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE3EF,iBAAA,QF6EE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECtEE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCDuDJ,oCCtDM,WAAA,MDqEN,yEACE,iBAAA,QAGF,0CACE,QAAA,QAAA,OACA,OAAA,SAAA,QACA,mBAAA,OAAA,kBAAA,OACA,MAAA,QE9FF,iBAAA,QFgGE,eAAA,KACA,aAAA,QACA,aAAA,MACA,aAAA,EACA,wBAAA,IACA,cAAA,ECzFE,mBAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCD0EJ,0CCzEM,mBAAA,KAAA,WAAA,MDwFN,+EACE,iBAAA,QASJ,wBACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,EACA,cAAA,EACA,YAAA,IACA,MAAA,QACA,iBAAA,YACA,OAAA,MAAA,YACA,aAAA,IAAA,EAEA,wCAAA,wCAEE,cAAA,EACA,aAAA,EAWJ,iBACE,WAAA,0BACA,QAAA,OAAA,MfmJI,UAAA,QClRF,cAAA,McmIF,uCACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAGF,6CACE,QAAA,OAAA,MACA,OAAA,QAAA,OACA,mBAAA,MAAA,kBAAA,MAIJ,iBACE,WAAA,yBACA,QAAA,MAAA,KfgII,UAAA,QClRF,cAAA,McsJF,uCACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAGF,6CACE,QAAA,MAAA,KACA,OAAA,OAAA,MACA,mBAAA,KAAA,kBAAA,KAQF,sBACE,WAAA,2BAGF,yBACE,WAAA,0BAGF,yBACE,WAAA,yBAKJ,oBACE,MAAA,KACA,OAAA,KACA,QAAA,QAEA,mDACE,OAAA,QAGF,uCACE,OAAA,Md/LA,cAAA,OcmMF,0CACE,OAAA,MdpMA,cAAA,OiBdJ,aACE,QAAA,MACA,MAAA,KACA,QAAA,QAAA,QAAA,QAAA,OAEA,mBAAA,oBlB2RI,UAAA,KkBxRJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,iBAAA,KACA,iBAAA,gOACA,kBAAA,UACA,oBAAA,MAAA,OAAA,OACA,gBAAA,KAAA,KACA,OAAA,IAAA,MAAA,QjBFE,cAAA,OeHE,WAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YESJ,mBAAA,KAAA,gBAAA,KAAA,WAAA,KFLI,uCEfN,aFgBQ,WAAA,MEMN,mBACE,aAAA,QACA,QAAA,EAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,uBAAA,mCAEE,cAAA,OACA,iBAAA,KAGF,sBAEE,iBAAA,QAKF,4BACE,MAAA,YACA,YAAA,EAAA,EAAA,EAAA,QAIJ,gBACE,YAAA,OACA,eAAA,OACA,aAAA,MlByOI,UAAA,QkBrON,gBACE,YAAA,MACA,eAAA,MACA,aAAA,KlBkOI,UAAA,QmBjSN,YACE,QAAA,MACA,WAAA,OACA,aAAA,MACA,cAAA,QAEA,8BACE,MAAA,KACA,YAAA,OAIJ,kBACE,MAAA,IACA,OAAA,IACA,WAAA,MACA,eAAA,IACA,iBAAA,KACA,kBAAA,UACA,oBAAA,OACA,gBAAA,QACA,OAAA,IAAA,MAAA,gBACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KACA,2BAAA,MAAA,aAAA,MAGA,iClBXE,cAAA,MkBeF,8BAEE,cAAA,IAGF,yBACE,OAAA,gBAGF,wBACE,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,0BACE,iBAAA,QACA,aAAA,QAEA,yCAII,iBAAA,8NAIJ,sCAII,iBAAA,sIAKN,+CACE,iBAAA,QACA,aAAA,QAKE,iBAAA,wNAIJ,2BACE,eAAA,KACA,OAAA,KACA,QAAA,GAOA,6CAAA,8CACE,QAAA,GAcN,aACE,aAAA,MAEA,+BACE,MAAA,IACA,YAAA,OACA,iBAAA,uJACA,oBAAA,KAAA,OlB9FA,cAAA,IeHE,WAAA,oBAAA,KAAA,YAIA,uCGyFJ,+BHxFM,WAAA,MGgGJ,qCACE,iBAAA,yIAGF,uCACE,oBAAA,MAAA,OAKE,iBAAA,sIAMR,mBACE,QAAA,aACA,aAAA,KAGF,WACE,SAAA,SACA,KAAA,cACA,eAAA,KAIE,yBAAA,0BACE,eAAA,KACA,OAAA,KACA,QAAA,IC9IN,YACE,MAAA,KACA,OAAA,OACA,QAAA,EACA,iBAAA,YACA,mBAAA,KAAA,gBAAA,KAAA,WAAA,KAEA,kBACE,QAAA,EAIA,wCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAC1B,oCAA0B,WAAA,EAAA,EAAA,EAAA,IAAA,IAAA,CAAA,EAAA,EAAA,EAAA,OAAA,qBAG5B,8BACE,OAAA,EAGF,kCACE,MAAA,KACA,OAAA,KACA,WAAA,QHzBF,iBAAA,QG2BE,OAAA,EnBZA,cAAA,KeHE,mBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YImBF,mBAAA,KAAA,WAAA,KJfE,uCIMJ,kCJLM,mBAAA,KAAA,WAAA,MIgBJ,yCHjCF,iBAAA,QGsCA,2CACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnB7BA,cAAA,KmBkCF,8BACE,MAAA,KACA,OAAA,KHnDF,iBAAA,QGqDE,OAAA,EnBtCA,cAAA,KeHE,gBAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAAA,WAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YI6CF,gBAAA,KAAA,WAAA,KJzCE,uCIiCJ,8BJhCM,gBAAA,KAAA,WAAA,MI0CJ,qCH3DF,iBAAA,QGgEA,8BACE,MAAA,KACA,OAAA,MACA,MAAA,YACA,OAAA,QACA,iBAAA,QACA,aAAA,YnBvDA,cAAA,KmB4DF,qBACE,eAAA,KAEA,2CACE,iBAAA,QAGF,uCACE,iBAAA,QCvFN,eACE,SAAA,SAEA,6BtB+iFF,4BsB7iFI,OAAA,mBACA,YAAA,KAGF,qBACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,OAAA,KACA,QAAA,KAAA,OACA,eAAA,KACA,OAAA,IAAA,MAAA,YACA,iBAAA,EAAA,ELDE,WAAA,QAAA,IAAA,WAAA,CAAA,UAAA,IAAA,YAIA,uCKXJ,qBLYM,WAAA,MKCN,6BACE,QAAA,KAAA,OAEA,+CACE,MAAA,YADF,0CACE,MAAA,YAGF,0DAEE,YAAA,SACA,eAAA,QAHF,mCAAA,qDAEE,YAAA,SACA,eAAA,QAGF,8CACE,YAAA,SACA,eAAA,QAIJ,4BACE,YAAA,SACA,eAAA,QAMA,gEACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBAFF,yCtBmjFJ,2DACA,kCsBnjFM,QAAA,IACA,UAAA,WAAA,mBAAA,mBAKF,oDACE,QAAA,IACA,UAAA,WAAA,mBAAA,mBCtDN,aACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,QACA,MAAA,KAEA,2BvB2mFF,0BuBzmFI,SAAA,SACA,KAAA,EAAA,EAAA,KACA,MAAA,GACA,UAAA,EAIF,iCvBymFF,gCuBvmFI,QAAA,EAMF,kBACE,SAAA,SACA,QAAA,EAEA,wBACE,QAAA,EAWN,kBACE,QAAA,KACA,YAAA,OACA,QAAA,QAAA,OtBsPI,UAAA,KsBpPJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,YAAA,OACA,iBAAA,QACA,OAAA,IAAA,MAAA,QrBpCE,cAAA,OFuoFJ,qBuBzlFA,8BvBulFA,6BACA,kCuBplFE,QAAA,MAAA,KtBgOI,UAAA,QClRF,cAAA,MFgpFJ,qBuBzlFA,8BvBulFA,6BACA,kCuBplFE,QAAA,OAAA,MtBuNI,UAAA,QClRF,cAAA,MqBgEJ,6BvBulFA,6BuBrlFE,cAAA,KvB0lFF,uEuB7kFI,8FrB/DA,wBAAA,EACA,2BAAA,EFgpFJ,iEuB3kFI,2FrBtEA,wBAAA,EACA,2BAAA,EqBgFF,0IACE,YAAA,KrBpEA,uBAAA,EACA,0BAAA,EsBzBF,gBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,eACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OFmsFJ,0BACA,yBwBrqFI,sCxBmqFJ,qCwBjqFM,QAAA,MA9CF,uBAAA,mCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2OACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,6BAAA,yCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,2CAAA,+BAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,sBAAA,kCAiFE,aAAA,QAGE,kDAAA,gDAAA,8DAAA,4DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2OACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,4BAAA,wCACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,2BAAA,uCAsGE,aAAA,QAEA,mCAAA,+CACE,iBAAA,QAGF,iCAAA,6CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,6CAAA,yDACE,MAAA,QAKJ,qDACE,YAAA,KAvHF,oCxBwwFJ,mCwBxwFI,gDxBuwFJ,+CwBxoFQ,QAAA,EAIF,0CxB0oFN,yCwB1oFM,sDxByoFN,qDwBxoFQ,QAAA,EAjHN,kBACE,QAAA,KACA,MAAA,KACA,WAAA,OvByQE,UAAA,OuBtQF,MAAA,QAGF,iBACE,SAAA,SACA,IAAA,KACA,QAAA,EACA,QAAA,KACA,UAAA,KACA,QAAA,OAAA,MACA,WAAA,MvB4PE,UAAA,QuBzPF,MAAA,KACA,iBAAA,mBtB1BA,cAAA,OF4xFJ,8BACA,6BwB9vFI,0CxB4vFJ,yCwB1vFM,QAAA,MA9CF,yBAAA,qCAoDE,aAAA,QAGE,cAAA,qBACA,iBAAA,2TACA,kBAAA,UACA,oBAAA,MAAA,wBAAA,OACA,gBAAA,sBAAA,sBAGF,+BAAA,2CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBAhEJ,6CAAA,iCAyEI,cAAA,qBACA,oBAAA,IAAA,wBAAA,MAAA,wBA1EJ,wBAAA,oCAiFE,aAAA,QAGE,oDAAA,kDAAA,gEAAA,8DAEE,cAAA,SACA,iBAAA,+NAAA,CAAA,2TACA,oBAAA,MAAA,OAAA,MAAA,CAAA,OAAA,MAAA,QACA,gBAAA,KAAA,IAAA,CAAA,sBAAA,sBAIJ,8BAAA,0CACE,aAAA,QACA,WAAA,EAAA,EAAA,EAAA,OAAA,oBA/FJ,6BAAA,yCAsGE,aAAA,QAEA,qCAAA,iDACE,iBAAA,QAGF,mCAAA,+CACE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,+CAAA,2DACE,MAAA,QAKJ,uDACE,YAAA,KAvHF,sCxBi2FJ,qCwBj2FI,kDxBg2FJ,iDwB/tFQ,QAAA,EAEF,4CxBmuFN,2CwBnuFM,wDxBkuFN,uDwBjuFQ,QAAA,ECtIR,KACE,QAAA,aAEA,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,OACA,gBAAA,KAEA,eAAA,OACA,OAAA,QACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,iBAAA,YACA,OAAA,IAAA,MAAA,YC8GA,QAAA,QAAA,OzBsKI,UAAA,KClRF,cAAA,OeHE,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCQhBN,KRiBQ,WAAA,MQAN,WACE,MAAA,QAIF,sBAAA,WAEE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAcF,cAAA,cAAA,uBAGE,eAAA,KACA,QAAA,IAYF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,eCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,qBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,gCAAA,qBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,iCAAA,kCAAA,sBAAA,sBAAA,qCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,uCAAA,wCAAA,4BAAA,4BAAA,2CAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,wBAAA,wBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,aCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,mBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,8BAAA,mBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,+BAAA,gCAAA,oBAAA,oBAAA,mCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,qCAAA,sCAAA,0BAAA,0BAAA,yCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,sBAAA,sBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,YCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,kBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,6BAAA,kBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAIJ,8BAAA,+BAAA,mBAAA,mBAAA,kCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,oCAAA,qCAAA,yBAAA,yBAAA,wCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,qBAAA,qBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,WCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,iBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,4BAAA,iBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,6BAAA,8BAAA,kBAAA,kBAAA,iCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,mCAAA,oCAAA,wBAAA,wBAAA,uCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,oBAAA,oBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDZF,UCvCA,MAAA,KRhBA,iBAAA,QQkBA,aAAA,QAGA,gBACE,MAAA,KRtBF,iBAAA,QQwBE,aAAA,QAGF,2BAAA,gBAEE,MAAA,KR7BF,iBAAA,QQ+BE,aAAA,QAKE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAIJ,4BAAA,6BAAA,iBAAA,iBAAA,gCAKE,MAAA,KACA,iBAAA,QAGA,aAAA,QAEA,kCAAA,mCAAA,uBAAA,uBAAA,sCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,mBAAA,mBAEE,MAAA,KACA,iBAAA,QAGA,aAAA,QDNF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,uBCmBA,MAAA,QACA,aAAA,QAEA,6BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,wCAAA,6BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,yCAAA,0CAAA,8BAAA,4CAAA,8BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,+CAAA,gDAAA,oCAAA,kDAAA,oCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,gCAAA,gCAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,oBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,oBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YDvDF,qBCmBA,MAAA,QACA,aAAA,QAEA,2BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,sCAAA,2BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,uCAAA,wCAAA,4BAAA,0CAAA,4BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,6CAAA,8CAAA,kCAAA,gDAAA,kCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,8BAAA,8BAEE,MAAA,QACA,iBAAA,YDvDF,oBCmBA,MAAA,QACA,aAAA,QAEA,0BACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,qCAAA,0BAEE,WAAA,EAAA,EAAA,EAAA,OAAA,mBAGF,sCAAA,uCAAA,2BAAA,yCAAA,2BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,4CAAA,6CAAA,iCAAA,+CAAA,iCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,mBAKN,6BAAA,6BAEE,MAAA,QACA,iBAAA,YDvDF,mBCmBA,MAAA,QACA,aAAA,QAEA,yBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,oCAAA,yBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,qBAGF,qCAAA,sCAAA,0BAAA,wCAAA,0BAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,2CAAA,4CAAA,gCAAA,8CAAA,gCAKI,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKN,4BAAA,4BAEE,MAAA,QACA,iBAAA,YDvDF,kBCmBA,MAAA,QACA,aAAA,QAEA,wBACE,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,mCAAA,wBAEE,WAAA,EAAA,EAAA,EAAA,OAAA,kBAGF,oCAAA,qCAAA,yBAAA,uCAAA,yBAKE,MAAA,KACA,iBAAA,QACA,aAAA,QAEA,0CAAA,2CAAA,+BAAA,6CAAA,+BAKI,WAAA,EAAA,EAAA,EAAA,OAAA,kBAKN,2BAAA,2BAEE,MAAA,QACA,iBAAA,YD3CJ,UACE,YAAA,IACA,MAAA,QACA,gBAAA,UAEA,gBACE,MAAA,QAQF,mBAAA,mBAEE,MAAA,QAWJ,mBAAA,QCuBE,QAAA,MAAA,KzBsKI,UAAA,QClRF,cAAA,MuByFJ,mBAAA,QCmBE,QAAA,OAAA,MzBsKI,UAAA,QClRF,cAAA,MyBnBJ,MVgBM,WAAA,QAAA,KAAA,OAIA,uCUpBN,MVqBQ,WAAA,MUlBN,iBACE,QAAA,EAMF,qBACE,QAAA,KAIJ,YACE,OAAA,EACA,SAAA,OVDI,WAAA,OAAA,KAAA,KAIA,uCULN,YVMQ,WAAA,MUDN,gCACE,MAAA,EACA,OAAA,KVNE,WAAA,MAAA,KAAA,KAIA,uCUAJ,gCVCM,WAAA,MjBs3GR,UADA,SAEA,W4B34GA,QAIE,SAAA,SAGF,iBACE,YAAA,OCqBE,wBACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAhCJ,WAAA,KAAA,MACA,aAAA,KAAA,MAAA,YACA,cAAA,EACA,YAAA,KAAA,MAAA,YAqDE,8BACE,YAAA,ED3CN,eACE,SAAA,SACA,QAAA,KACA,QAAA,KACA,UAAA,MACA,QAAA,MAAA,EACA,OAAA,E3B+QI,UAAA,K2B7QJ,MAAA,QACA,WAAA,KACA,WAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,gB1BVE,cAAA,O0BcF,+BACE,IAAA,KACA,KAAA,EACA,WAAA,QAYA,qBACE,cAAA,MAEA,qCACE,MAAA,KACA,KAAA,EAIJ,mBACE,cAAA,IAEA,mCACE,MAAA,EACA,KAAA,KnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,yBmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,wBACE,cAAA,MAEA,wCACE,MAAA,KACA,KAAA,EAIJ,sBACE,cAAA,IAEA,sCACE,MAAA,EACA,KAAA,MnBCJ,0BmBfA,yBACE,cAAA,MAEA,yCACE,MAAA,KACA,KAAA,EAIJ,uBACE,cAAA,IAEA,uCACE,MAAA,EACA,KAAA,MAUN,uCACE,IAAA,KACA,OAAA,KACA,WAAA,EACA,cAAA,QC9CA,gCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAzBJ,WAAA,EACA,aAAA,KAAA,MAAA,YACA,cAAA,KAAA,MACA,YAAA,KAAA,MAAA,YA8CE,sCACE,YAAA,ED0BJ,wCACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,YAAA,QC5DA,iCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAlBJ,WAAA,KAAA,MAAA,YACA,aAAA,EACA,cAAA,KAAA,MAAA,YACA,YAAA,KAAA,MAuCE,uCACE,YAAA,EDoCF,iCACE,eAAA,EAMJ,0CACE,IAAA,EACA,MAAA,KACA,KAAA,KACA,WAAA,EACA,aAAA,QC7EA,mCACE,QAAA,aACA,YAAA,OACA,eAAA,OACA,QAAA,GAWA,mCACE,QAAA,KAGF,oCACE,QAAA,aACA,aAAA,OACA,eAAA,OACA,QAAA,GA9BN,WAAA,KAAA,MAAA,YACA,aAAA,KAAA,MACA,cAAA,KAAA,MAAA,YAiCE,yCACE,YAAA,EDqDF,oCACE,eAAA,EAON,kBACE,OAAA,EACA,OAAA,MAAA,EACA,SAAA,OACA,WAAA,IAAA,MAAA,gBAMF,eACE,QAAA,MACA,MAAA,KACA,QAAA,OAAA,KACA,MAAA,KACA,YAAA,IACA,MAAA,QACA,WAAA,QACA,gBAAA,KACA,YAAA,OACA,iBAAA,YACA,OAAA,EAcA,qBAAA,qBAEE,MAAA,QVzJF,iBAAA,QU8JA,sBAAA,sBAEE,MAAA,KACA,gBAAA,KVjKF,iBAAA,QUqKA,wBAAA,wBAEE,MAAA,QACA,eAAA,KACA,iBAAA,YAMJ,oBACE,QAAA,MAIF,iBACE,QAAA,MACA,QAAA,MAAA,KACA,cAAA,E3B0GI,UAAA,Q2BxGJ,MAAA,QACA,YAAA,OAIF,oBACE,QAAA,MACA,QAAA,OAAA,KACA,MAAA,QAIF,oBACE,MAAA,QACA,iBAAA,QACA,aAAA,gBAGA,mCACE,MAAA,QAEA,yCAAA,yCAEE,MAAA,KVhNJ,iBAAA,sBUoNE,0CAAA,0CAEE,MAAA,KVtNJ,iBAAA,QU0NE,4CAAA,4CAEE,MAAA,QAIJ,sCACE,aAAA,gBAGF,wCACE,MAAA,QAGF,qCACE,MAAA,QE5OJ,W9B2rHA,oB8BzrHE,SAAA,SACA,QAAA,YACA,eAAA,O9B6rHF,yB8B3rHE,gBACE,SAAA,SACA,KAAA,EAAA,EAAA,K9BmsHJ,4CACA,0CAIA,gCADA,gCADA,+BADA,+B8BhsHE,mC9ByrHF,iCAIA,uBADA,uBADA,sBADA,sB8BprHI,QAAA,EAKJ,aACE,QAAA,KACA,UAAA,KACA,gBAAA,WAEA,0BACE,MAAA,K9BgsHJ,wC8B1rHE,kCAEE,YAAA,K9B4rHJ,4C8BxrHE,uD5BRE,wBAAA,EACA,2BAAA,EFqsHJ,6C8BrrHE,+B9BorHF,iCEvrHI,uBAAA,EACA,0BAAA,E4BqBJ,uBACE,cAAA,SACA,aAAA,SAEA,8BAAA,uCAAA,sCAGE,YAAA,EAGF,0CACE,aAAA,EAIJ,0CAAA,+BACE,cAAA,QACA,aAAA,QAGF,0CAAA,+BACE,cAAA,OACA,aAAA,OAoBF,oBACE,eAAA,OACA,YAAA,WACA,gBAAA,OAEA,yB9BmpHF,+B8BjpHI,MAAA,K9BqpHJ,iD8BlpHE,2CAEE,WAAA,K9BopHJ,qD8BhpHE,gE5BvFE,2BAAA,EACA,0BAAA,EF2uHJ,sD8BhpHE,8B5B1GE,uBAAA,EACA,wBAAA,E6BxBJ,KACE,QAAA,KACA,UAAA,KACA,aAAA,EACA,cAAA,EACA,WAAA,KAGF,UACE,QAAA,MACA,QAAA,MAAA,KAGA,MAAA,QACA,gBAAA,KdHI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,YAIA,uCcPN,UdQQ,WAAA,McCN,gBAAA,gBAEE,MAAA,QAKF,mBACE,MAAA,QACA,eAAA,KACA,OAAA,QAQJ,UACE,cAAA,IAAA,MAAA,QAEA,oBACE,cAAA,KACA,WAAA,IACA,OAAA,IAAA,MAAA,Y7BlBA,uBAAA,OACA,wBAAA,O6BoBA,0BAAA,0BAEE,aAAA,QAAA,QAAA,QAEA,UAAA,QAGF,6BACE,MAAA,QACA,iBAAA,YACA,aAAA,Y/BixHN,mC+B7wHE,2BAEE,MAAA,QACA,iBAAA,KACA,aAAA,QAAA,QAAA,KAGF,yBAEE,WAAA,K7B5CA,uBAAA,EACA,wBAAA,E6BuDF,qBACE,WAAA,IACA,OAAA,E7BnEA,cAAA,O6BuEF,4B/BmwHF,2B+BjwHI,MAAA,KbxFF,iBAAA,QlB+1HF,oB+B5vHE,oBAEE,KAAA,EAAA,EAAA,KACA,WAAA,O/B+vHJ,yB+B1vHE,yBAEE,WAAA,EACA,UAAA,EACA,WAAA,OAMF,8B/BuvHF,mC+BtvHI,MAAA,KAUF,uBACE,QAAA,KAEF,qBACE,QAAA,MCxHJ,QACE,SAAA,SACA,QAAA,KACA,UAAA,KACA,YAAA,OACA,gBAAA,cACA,YAAA,MAEA,eAAA,MAOA,mBhCs2HF,yBAGA,sBADA,sBADA,sBAGA,sBACA,uBgC12HI,QAAA,KACA,UAAA,QACA,YAAA,OACA,gBAAA,cAoBJ,cACE,YAAA,SACA,eAAA,SACA,aAAA,K/B2OI,UAAA,Q+BzOJ,gBAAA,KACA,YAAA,OAaF,YACE,QAAA,KACA,eAAA,OACA,aAAA,EACA,cAAA,EACA,WAAA,KAEA,sBACE,cAAA,EACA,aAAA,EAGF,2BACE,SAAA,OASJ,aACE,YAAA,MACA,eAAA,MAYF,iBACE,WAAA,KACA,UAAA,EAGA,YAAA,OAIF,gBACE,QAAA,OAAA,O/B6KI,UAAA,Q+B3KJ,YAAA,EACA,iBAAA,YACA,OAAA,IAAA,MAAA,Y9BzGE,cAAA,OeHE,WAAA,WAAA,KAAA,YAIA,uCemGN,gBflGQ,WAAA,Me2GN,sBACE,gBAAA,KAGF,sBACE,gBAAA,KACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAMJ,qBACE,QAAA,aACA,MAAA,MACA,OAAA,MACA,eAAA,OACA,kBAAA,UACA,oBAAA,OACA,gBAAA,KAGF,mBACE,WAAA,6BACA,WAAA,KvB1FE,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC+yHV,oCgC7yHQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCo2HV,oCgCl2HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,yBuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCy5HV,oCgCv5HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,kBAEI,UAAA,OACA,gBAAA,WAEA,8BACE,eAAA,IAEA,6CACE,SAAA,SAGF,wCACE,cAAA,MACA,aAAA,MAIJ,qCACE,SAAA,QAGF,mCACE,QAAA,eACA,WAAA,KAGF,kCACE,QAAA,KAGF,oCACE,QAAA,KAGF,6BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhC88HV,oCgC58HQ,iCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,kCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SvBhKN,0BuBsGA,mBAEI,UAAA,OACA,gBAAA,WAEA,+BACE,eAAA,IAEA,8CACE,SAAA,SAGF,yCACE,cAAA,MACA,aAAA,MAIJ,sCACE,SAAA,QAGF,oCACE,QAAA,eACA,WAAA,KAGF,mCACE,QAAA,KAGF,qCACE,QAAA,KAGF,8BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCmgIV,qCgCjgIQ,kCAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,mCACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,SA1DN,eAEI,UAAA,OACA,gBAAA,WAEA,2BACE,eAAA,IAEA,0CACE,SAAA,SAGF,qCACE,cAAA,MACA,aAAA,MAIJ,kCACE,SAAA,QAGF,gCACE,QAAA,eACA,WAAA,KAGF,+BACE,QAAA,KAGF,iCACE,QAAA,KAGF,0BACE,SAAA,QACA,OAAA,EACA,QAAA,KACA,UAAA,EACA,WAAA,kBACA,iBAAA,YACA,aAAA,EACA,YAAA,EfhMJ,WAAA,KekMI,UAAA,KhCujIV,iCgCrjIQ,8BAEE,OAAA,KACA,WAAA,EACA,cAAA,EAGF,+BACE,QAAA,KACA,UAAA,EACA,QAAA,EACA,WAAA,QAcR,4BACE,MAAA,eAEA,kCAAA,kCAEE,MAAA,eAKF,oCACE,MAAA,gBAEA,0CAAA,0CAEE,MAAA,eAGF,6CACE,MAAA,ehCqiIR,2CgCjiII,0CAEE,MAAA,eAIJ,8BACE,MAAA,gBACA,aAAA,eAGF,mCACE,iBAAA,4OAGF,2BACE,MAAA,gBAEA,6BhC8hIJ,mCADA,mCgC1hIM,MAAA,eAOJ,2BACE,MAAA,KAEA,iCAAA,iCAEE,MAAA,KAKF,mCACE,MAAA,sBAEA,yCAAA,yCAEE,MAAA,sBAGF,4CACE,MAAA,sBhCqhIR,0CgCjhII,yCAEE,MAAA,KAIJ,6BACE,MAAA,sBACA,aAAA,qBAGF,kCACE,iBAAA,kPAGF,0BACE,MAAA,sBACA,4BhC+gIJ,kCADA,kCgC3gIM,MAAA,KCvUN,MACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,UAAA,EAEA,UAAA,WACA,iBAAA,KACA,gBAAA,WACA,OAAA,IAAA,MAAA,iB/BME,cAAA,O+BFF,SACE,aAAA,EACA,YAAA,EAGF,kBACE,WAAA,QACA,cAAA,QAEA,8BACE,iBAAA,E/BCF,uBAAA,mBACA,wBAAA,mB+BEA,6BACE,oBAAA,E/BUF,2BAAA,mBACA,0BAAA,mB+BJF,+BjCk1IF,+BiCh1II,WAAA,EAIJ,WAGE,KAAA,EAAA,EAAA,KACA,QAAA,KAAA,KAIF,YACE,cAAA,MAGF,eACE,WAAA,QACA,cAAA,EAGF,sBACE,cAAA,EAQA,sBACE,YAAA,KAQJ,aACE,QAAA,MAAA,KACA,cAAA,EAEA,iBAAA,gBACA,cAAA,IAAA,MAAA,iBAEA,yB/BpEE,cAAA,mBAAA,mBAAA,EAAA,E+ByEJ,aACE,QAAA,MAAA,KAEA,iBAAA,gBACA,WAAA,IAAA,MAAA,iBAEA,wB/B/EE,cAAA,EAAA,EAAA,mBAAA,mB+ByFJ,kBACE,aAAA,OACA,cAAA,OACA,YAAA,OACA,cAAA,EAUF,mBACE,aAAA,OACA,YAAA,OAIF,kBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,K/BnHE,cAAA,mB+BuHJ,UjCozIA,iBADA,ciChzIE,MAAA,KAGF,UjCmzIA,cEv6II,uBAAA,mBACA,wBAAA,mB+BwHJ,UjCozIA,iBE/5II,2BAAA,mBACA,0BAAA,mB+BuHF,kBACE,cAAA,OxBpGA,yBwBgGJ,YAQI,QAAA,KACA,UAAA,IAAA,KAGA,kBAEE,KAAA,EAAA,EAAA,GACA,cAAA,EAEA,wBACE,YAAA,EACA,YAAA,EAKA,mC/BpJJ,wBAAA,EACA,2BAAA,EF+7IJ,gDiCzyIU,iDAGE,wBAAA,EjC0yIZ,gDiCxyIU,oDAGE,2BAAA,EAIJ,oC/BrJJ,uBAAA,EACA,0BAAA,EF67IJ,iDiCtyIU,kDAGE,uBAAA,EjCuyIZ,iDiCryIU,qDAGE,0BAAA,GC7MZ,kBACE,SAAA,SACA,QAAA,KACA,YAAA,OACA,MAAA,KACA,QAAA,KAAA,QjC4RI,UAAA,KiC1RJ,MAAA,QACA,WAAA,KACA,iBAAA,KACA,OAAA,EhCKE,cAAA,EgCHF,gBAAA,KjBAI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,WAAA,CAAA,cAAA,KAAA,KAIA,uCiBhBN,kBjBiBQ,WAAA,MiBFN,kCACE,MAAA,QACA,iBAAA,QACA,WAAA,MAAA,EAAA,KAAA,EAAA,iBAEA,yCACE,iBAAA,gRACA,UAAA,gBAKJ,yBACE,YAAA,EACA,MAAA,QACA,OAAA,QACA,YAAA,KACA,QAAA,GACA,iBAAA,gRACA,kBAAA,UACA,gBAAA,QjBvBE,WAAA,UAAA,IAAA,YAIA,uCiBWJ,yBjBVM,WAAA,MiBsBN,wBACE,QAAA,EAGF,wBACE,QAAA,EACA,aAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAIJ,kBACE,cAAA,EAGF,gBACE,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,8BhCnCE,uBAAA,OACA,wBAAA,OgCqCA,gDhCtCA,uBAAA,mBACA,wBAAA,mBgC0CF,oCACE,WAAA,EAIF,6BhClCE,2BAAA,OACA,0BAAA,OgCqCE,yDhCtCF,2BAAA,mBACA,0BAAA,mBgC0CA,iDhC3CA,2BAAA,OACA,0BAAA,OgCgDJ,gBACE,QAAA,KAAA,QASA,qCACE,aAAA,EAGF,iCACE,aAAA,EACA,YAAA,EhCxFA,cAAA,EgC2FA,6CAAgB,WAAA,EAChB,4CAAe,cAAA,EAEf,mDhC9FA,cAAA,EiCnBJ,YACE,QAAA,KACA,UAAA,KACA,QAAA,EAAA,EACA,cAAA,KAEA,WAAA,KAOA,kCACE,aAAA,MAEA,0CACE,MAAA,KACA,cAAA,MACA,MAAA,QACA,QAAA,kCAIJ,wBACE,MAAA,QCzBJ,YACE,QAAA,KhCGA,aAAA,EACA,WAAA,KgCAF,WACE,SAAA,SACA,QAAA,MACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,QnBKI,WAAA,MAAA,KAAA,WAAA,CAAA,iBAAA,KAAA,WAAA,CAAA,aAAA,KAAA,WAAA,CAAA,WAAA,KAAA,YAIA,uCmBfN,WnBgBQ,WAAA,MmBPN,iBACE,QAAA,EACA,MAAA,QAEA,iBAAA,QACA,aAAA,QAGF,iBACE,QAAA,EACA,MAAA,QACA,iBAAA,QACA,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBAKF,wCACE,YAAA,KAGF,6BACE,QAAA,EACA,MAAA,KlBlCF,iBAAA,QkBoCE,aAAA,QAGF,+BACE,MAAA,QACA,eAAA,KACA,iBAAA,KACA,aAAA,QC3CF,WACE,QAAA,QAAA,OAOI,kCnCqCJ,uBAAA,OACA,0BAAA,OmChCI,iCnCiBJ,wBAAA,OACA,2BAAA,OmChCF,0BACE,QAAA,OAAA,OpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MmChCF,0BACE,QAAA,OAAA,MpCgSE,UAAA,QoCzRE,iDnCqCJ,uBAAA,MACA,0BAAA,MmChCI,gDnCiBJ,wBAAA,MACA,2BAAA,MoC/BJ,OACE,QAAA,aACA,QAAA,MAAA,MrC8RI,UAAA,MqC5RJ,YAAA,IACA,YAAA,EACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,eAAA,SpCKE,cAAA,OoCAF,aACE,QAAA,KAKJ,YACE,SAAA,SACA,IAAA,KCvBF,OACE,SAAA,SACA,QAAA,KAAA,KACA,cAAA,KACA,OAAA,IAAA,MAAA,YrCWE,cAAA,OqCNJ,eAEE,MAAA,QAIF,YACE,YAAA,IAQF,mBACE,cAAA,KAGA,8BACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,QAAA,EACA,QAAA,QAAA,KAeF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,iBClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,6BACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QD6CF,eClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,2BACE,MAAA,QD6CF,cClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,0BACE,MAAA,QD6CF,aClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,yBACE,MAAA,QD6CF,YClDA,MAAA,QtBEA,iBAAA,QsBAA,aAAA,QAEA,wBACE,MAAA,QCHF,wCACE,GAAK,sBAAA,MADP,gCACE,GAAK,sBAAA,MAKT,UACE,QAAA,KACA,OAAA,KACA,SAAA,OxCwRI,UAAA,OwCtRJ,iBAAA,QvCIE,cAAA,OuCCJ,cACE,QAAA,KACA,eAAA,OACA,gBAAA,OACA,SAAA,OACA,MAAA,KACA,WAAA,OACA,YAAA,OACA,iBAAA,QxBZI,WAAA,MAAA,IAAA,KAIA,uCwBAN,cxBCQ,WAAA,MwBWR,sBvBYE,iBAAA,iKuBVA,gBAAA,KAAA,KAIA,uBACE,kBAAA,GAAA,OAAA,SAAA,qBAAA,UAAA,GAAA,OAAA,SAAA,qBAGE,uCAJJ,uBAKM,kBAAA,KAAA,UAAA,MCvCR,YACE,QAAA,KACA,eAAA,OAGA,aAAA,EACA,cAAA,ExCSE,cAAA,OwCLJ,qBACE,gBAAA,KACA,cAAA,QAEA,gCAEE,QAAA,uBAAA,KACA,kBAAA,QAUJ,wBACE,MAAA,KACA,MAAA,QACA,WAAA,QAGA,8BAAA,8BAEE,QAAA,EACA,MAAA,QACA,gBAAA,KACA,iBAAA,QAGF,+BACE,MAAA,QACA,iBAAA,QASJ,iBACE,SAAA,SACA,QAAA,MACA,QAAA,MAAA,KACA,MAAA,QACA,gBAAA,KACA,iBAAA,KACA,OAAA,IAAA,MAAA,iBAEA,6BxCrCE,uBAAA,QACA,wBAAA,QwCwCF,4BxC3BE,2BAAA,QACA,0BAAA,QwC8BF,0BAAA,0BAEE,MAAA,QACA,eAAA,KACA,iBAAA,KAIF,wBACE,QAAA,EACA,MAAA,KACA,iBAAA,QACA,aAAA,QAGF,kCACE,iBAAA,EAEA,yCACE,WAAA,KACA,iBAAA,IAcF,uBACE,eAAA,IAGE,oDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,mDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,+CACE,WAAA,EAGF,yDACE,iBAAA,IACA,kBAAA,EAEA,gEACE,YAAA,KACA,kBAAA,IjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,yBiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,0BACE,eAAA,IAGE,uDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,sDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,kDACE,WAAA,EAGF,4DACE,iBAAA,IACA,kBAAA,EAEA,mEACE,YAAA,KACA,kBAAA,KjCpER,0BiC4CA,2BACE,eAAA,IAGE,wDxCrCJ,0BAAA,OAZA,wBAAA,EwCsDI,uDxCtDJ,wBAAA,OAYA,0BAAA,EwC+CI,mDACE,WAAA,EAGF,6DACE,iBAAA,IACA,kBAAA,EAEA,oEACE,YAAA,KACA,kBAAA,KAcZ,kBxC9HI,cAAA,EwCiIF,mCACE,aAAA,EAAA,EAAA,IAEA,8CACE,oBAAA,ECpJJ,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,2BACE,MAAA,QACA,iBAAA,QAGE,wDAAA,wDAEE,MAAA,QACA,iBAAA,QAGF,yDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,yBACE,MAAA,QACA,iBAAA,QAGE,sDAAA,sDAEE,MAAA,QACA,iBAAA,QAGF,uDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,wBACE,MAAA,QACA,iBAAA,QAGE,qDAAA,qDAEE,MAAA,QACA,iBAAA,QAGF,sDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,uBACE,MAAA,QACA,iBAAA,QAGE,oDAAA,oDAEE,MAAA,QACA,iBAAA,QAGF,qDACE,MAAA,KACA,iBAAA,QACA,aAAA,QAdN,sBACE,MAAA,QACA,iBAAA,QAGE,mDAAA,mDAEE,MAAA,QACA,iBAAA,QAGF,oDACE,MAAA,KACA,iBAAA,QACA,aAAA,QCbR,WACE,WAAA,YACA,MAAA,IACA,OAAA,IACA,QAAA,MAAA,MACA,MAAA,KACA,WAAA,YAAA,0TAAA,MAAA,CAAA,IAAA,KAAA,UACA,OAAA,E1COE,cAAA,O0CLF,QAAA,GAGA,iBACE,MAAA,KACA,gBAAA,KACA,QAAA,IAGF,iBACE,QAAA,EACA,WAAA,EAAA,EAAA,EAAA,OAAA,qBACA,QAAA,EAGF,oBAAA,oBAEE,eAAA,KACA,oBAAA,KAAA,iBAAA,KAAA,YAAA,KACA,QAAA,IAIJ,iBACE,OAAA,UAAA,gBAAA,iBCtCF,OACE,MAAA,MACA,UAAA,K5CmSI,UAAA,Q4ChSJ,eAAA,KACA,iBAAA,sBACA,gBAAA,YACA,OAAA,IAAA,MAAA,eACA,WAAA,EAAA,MAAA,KAAA,gB3CUE,cAAA,O2CPF,eACE,QAAA,EAGF,kBACE,QAAA,KAIJ,iBACE,MAAA,oBAAA,MAAA,iBAAA,MAAA,YACA,UAAA,KACA,eAAA,KAEA,mCACE,cAAA,OAIJ,cACE,QAAA,KACA,YAAA,OACA,QAAA,MAAA,OACA,MAAA,QACA,iBAAA,sBACA,gBAAA,YACA,cAAA,IAAA,MAAA,gB3CVE,uBAAA,mBACA,wBAAA,mB2CYF,yBACE,aAAA,SACA,YAAA,OAIJ,YACE,QAAA,OACA,UAAA,WC1CF,OACE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,KACA,MAAA,KACA,OAAA,KACA,WAAA,OACA,WAAA,KAGA,QAAA,EAOF,cACE,SAAA,SACA,MAAA,KACA,OAAA,MAEA,eAAA,KAGA,0B7BlBI,WAAA,UAAA,IAAA,S6BoBF,UAAA,mB7BhBE,uC6BcJ,0B7BbM,WAAA,M6BiBN,0BACE,UAAA,KAIF,kCACE,UAAA,YAIJ,yBACE,OAAA,kBAEA,wCACE,WAAA,KACA,SAAA,OAGF,qCACE,WAAA,KAIJ,uBACE,QAAA,KACA,YAAA,OACA,WAAA,kBAIF,eACE,SAAA,SACA,QAAA,KACA,eAAA,OACA,MAAA,KAGA,eAAA,KACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,e5C3DE,cAAA,M4C+DF,QAAA,EAIF,gBCpFE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,qBAAS,QAAA,EACT,qBAAS,QAAA,GDgFX,cACE,QAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KACA,cAAA,IAAA,MAAA,Q5CtEE,uBAAA,kBACA,wBAAA,kB4CwEF,yBACE,QAAA,MAAA,MACA,OAAA,OAAA,OAAA,OAAA,KAKJ,aACE,cAAA,EACA,YAAA,IAKF,YACE,SAAA,SAGA,KAAA,EAAA,EAAA,KACA,QAAA,KAIF,cACE,QAAA,KACA,UAAA,KACA,YAAA,EACA,YAAA,OACA,gBAAA,SACA,QAAA,OACA,WAAA,IAAA,MAAA,Q5CzFE,2BAAA,kBACA,0BAAA,kB4C8FF,gBACE,OAAA,OrC3EA,yBqCkFF,cACE,UAAA,MACA,OAAA,QAAA,KAGF,yBACE,OAAA,oBAGF,uBACE,WAAA,oBAOF,UAAY,UAAA,OrCnGV,yBqCuGF,U9CywKF,U8CvwKI,UAAA,OrCzGA,0BqC8GF,UAAY,UAAA,QASV,kBACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,iCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,gC5C/KF,cAAA,E4CmLE,8BACE,WAAA,KAGF,gC5CvLF,cAAA,EOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,4BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,0BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,yCACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,wC5C/KF,cAAA,E4CmLE,sCACE,WAAA,KAGF,wC5CvLF,cAAA,GOyDA,6BqC0GA,2BACE,MAAA,MACA,UAAA,KACA,OAAA,KACA,OAAA,EAEA,0CACE,OAAA,KACA,OAAA,E5C3KJ,cAAA,E4C+KE,yC5C/KF,cAAA,E4CmLE,uCACE,WAAA,KAGF,yC5CvLF,cAAA,G8ClBJ,SACE,SAAA,SACA,QAAA,KACA,QAAA,MACA,OAAA,ECJA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,Q+C1RJ,UAAA,WACA,QAAA,EAEA,cAAS,QAAA,GAET,wBACE,SAAA,SACA,QAAA,MACA,MAAA,MACA,OAAA,MAEA,gCACE,SAAA,SACA,QAAA,GACA,aAAA,YACA,aAAA,MAKN,6CAAA,gBACE,QAAA,MAAA,EAEA,4DAAA,+BACE,OAAA,EAEA,oEAAA,uCACE,IAAA,KACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAKN,+CAAA,gBACE,QAAA,EAAA,MAEA,8DAAA,+BACE,KAAA,EACA,MAAA,MACA,OAAA,MAEA,sEAAA,uCACE,MAAA,KACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAKN,gDAAA,mBACE,QAAA,MAAA,EAEA,+DAAA,kCACE,IAAA,EAEA,uEAAA,0CACE,OAAA,KACA,aAAA,EAAA,MAAA,MACA,oBAAA,KAKN,8CAAA,kBACE,QAAA,EAAA,MAEA,6DAAA,iCACE,MAAA,EACA,MAAA,MACA,OAAA,MAEA,qEAAA,yCACE,KAAA,KACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,eACE,UAAA,MACA,QAAA,OAAA,MACA,MAAA,KACA,WAAA,OACA,iBAAA,K9C7FE,cAAA,OgDnBJ,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,QAAA,MACA,UAAA,MDLA,YAAA,0BAEA,WAAA,OACA,YAAA,IACA,YAAA,IACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,OACA,WAAA,OACA,aAAA,OACA,YAAA,OACA,WAAA,KhDsRI,UAAA,QiDzRJ,UAAA,WACA,iBAAA,KACA,gBAAA,YACA,OAAA,IAAA,MAAA,ehDIE,cAAA,MgDAF,wBACE,SAAA,SACA,QAAA,MACA,MAAA,KACA,OAAA,MAEA,+BAAA,gCAEE,SAAA,SACA,QAAA,MACA,QAAA,GACA,aAAA,YACA,aAAA,MAMJ,4DAAA,+BACE,OAAA,mBAEA,oEAAA,uCACE,OAAA,EACA,aAAA,MAAA,MAAA,EACA,iBAAA,gBAGF,mEAAA,sCACE,OAAA,IACA,aAAA,MAAA,MAAA,EACA,iBAAA,KAMJ,8DAAA,+BACE,KAAA,mBACA,MAAA,MACA,OAAA,KAEA,sEAAA,uCACE,KAAA,EACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,gBAGF,qEAAA,sCACE,KAAA,IACA,aAAA,MAAA,MAAA,MAAA,EACA,mBAAA,KAMJ,+DAAA,kCACE,IAAA,mBAEA,uEAAA,0CACE,IAAA,EACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,gBAGF,sEAAA,yCACE,IAAA,IACA,aAAA,EAAA,MAAA,MAAA,MACA,oBAAA,KAKJ,wEAAA,2CACE,SAAA,SACA,IAAA,EACA,KAAA,IACA,QAAA,MACA,MAAA,KACA,YAAA,OACA,QAAA,GACA,cAAA,IAAA,MAAA,QAKF,6DAAA,iCACE,MAAA,mBACA,MAAA,MACA,OAAA,KAEA,qEAAA,yCACE,MAAA,EACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,gBAGF,oEAAA,wCACE,MAAA,IACA,aAAA,MAAA,EAAA,MAAA,MACA,kBAAA,KAqBN,gBACE,QAAA,MAAA,KACA,cAAA,EjDuJI,UAAA,KiDpJJ,iBAAA,QACA,cAAA,IAAA,MAAA,ehDtHE,uBAAA,kBACA,wBAAA,kBgDwHF,sBACE,QAAA,KAIJ,cACE,QAAA,KAAA,KACA,MAAA,QC/IF,UACE,SAAA,SAGF,wBACE,aAAA,MAGF,gBACE,SAAA,SACA,MAAA,KACA,SAAA,OCtBA,uBACE,QAAA,MACA,MAAA,KACA,QAAA,GDuBJ,eACE,SAAA,SACA,QAAA,KACA,MAAA,KACA,MAAA,KACA,aAAA,MACA,4BAAA,OAAA,oBAAA,OlClBI,WAAA,UAAA,IAAA,YAIA,uCkCQN,elCPQ,WAAA,MjBgzLR,oBACA,oBmDhyLA,sBAGE,QAAA,MnDmyLF,0BmD/xLA,8CAEE,UAAA,iBnDkyLF,4BmD/xLA,4CAEE,UAAA,kBAWA,8BACE,QAAA,EACA,oBAAA,QACA,UAAA,KnD0xLJ,uDACA,qDmDxxLE,qCAGE,QAAA,EACA,QAAA,EnDyxLJ,yCmDtxLE,2CAEE,QAAA,EACA,QAAA,ElC/DE,WAAA,QAAA,GAAA,IAIA,uCjBq1LN,yCmD7xLE,2ClCvDM,WAAA,MjB01LR,uBmDtxLA,uBAEE,SAAA,SACA,IAAA,EACA,OAAA,EACA,QAAA,EAEA,QAAA,KACA,YAAA,OACA,gBAAA,OACA,MAAA,IACA,QAAA,EACA,MAAA,KACA,WAAA,OACA,WAAA,IACA,OAAA,EACA,QAAA,GlCzFI,WAAA,QAAA,KAAA,KAIA,uCjB82LN,uBmDzyLA,uBlCpEQ,WAAA,MjBm3LR,6BADA,6BmD1xLE,6BAAA,6BAEE,MAAA,KACA,gBAAA,KACA,QAAA,EACA,QAAA,GAGJ,uBACE,KAAA,EAGF,uBACE,MAAA,EnD8xLF,4BmDzxLA,4BAEE,QAAA,aACA,MAAA,KACA,OAAA,KACA,kBAAA,UACA,oBAAA,IACA,gBAAA,KAAA,KAWF,4BACE,iBAAA,wPAEF,4BACE,iBAAA,yPAQF,qBACE,SAAA,SACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,KACA,gBAAA,OACA,QAAA,EAEA,aAAA,IACA,cAAA,KACA,YAAA,IACA,WAAA,KAEA,sCACE,WAAA,YACA,KAAA,EAAA,EAAA,KACA,MAAA,KACA,OAAA,IACA,QAAA,EACA,aAAA,IACA,YAAA,IACA,YAAA,OACA,OAAA,QACA,iBAAA,KACA,gBAAA,YACA,OAAA,EAEA,WAAA,KAAA,MAAA,YACA,cAAA,KAAA,MAAA,YACA,QAAA,GlC5KE,WAAA,QAAA,IAAA,KAIA,uCkCwJJ,sClCvJM,WAAA,MkC2KN,6BACE,QAAA,EASJ,kBACE,SAAA,SACA,MAAA,IACA,OAAA,QACA,KAAA,IACA,YAAA,QACA,eAAA,QACA,MAAA,KACA,WAAA,OnDoxLF,2CmD9wLE,2CAEE,OAAA,UAAA,eAGF,qDACE,iBAAA,KAGF,iCACE,MAAA,KE7NJ,kCACE,GAAK,UAAA,gBADP,0BACE,GAAK,UAAA,gBAIP,gBACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,OAAA,MAAA,MAAA,aACA,mBAAA,YAEA,cAAA,IACA,kBAAA,KAAA,OAAA,SAAA,eAAA,UAAA,KAAA,OAAA,SAAA,eAGF,mBACE,MAAA,KACA,OAAA,KACA,aAAA,KAQF,gCACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MANJ,wBACE,GACE,UAAA,SAEF,IACE,QAAA,EACA,UAAA,MAKJ,cACE,QAAA,aACA,MAAA,KACA,OAAA,KACA,eAAA,QACA,iBAAA,aAEA,cAAA,IACA,QAAA,EACA,kBAAA,KAAA,OAAA,SAAA,aAAA,UAAA,KAAA,OAAA,SAAA,aAGF,iBACE,MAAA,KACA,OAAA,KAIA,uCACE,gBrDo/LJ,cqDl/LM,2BAAA,KAAA,mBAAA,MCjEN,WACE,SAAA,MACA,OAAA,EACA,QAAA,KACA,QAAA,KACA,eAAA,OACA,UAAA,KAEA,WAAA,OACA,iBAAA,KACA,gBAAA,YACA,QAAA,ErCKI,WAAA,UAAA,IAAA,YAIA,uCqCpBN,WrCqBQ,WAAA,MqCLR,oBPdE,SAAA,MACA,IAAA,EACA,KAAA,EACA,QAAA,KACA,MAAA,MACA,OAAA,MACA,iBAAA,KAGA,yBAAS,QAAA,EACT,yBAAS,QAAA,GOQX,kBACE,QAAA,KACA,YAAA,OACA,gBAAA,cACA,QAAA,KAAA,KAEA,6BACE,QAAA,MAAA,MACA,WAAA,OACA,aAAA,OACA,cAAA,OAIJ,iBACE,cAAA,EACA,YAAA,IAGF,gBACE,UAAA,EACA,QAAA,KAAA,KACA,WAAA,KAGF,iBACE,IAAA,EACA,KAAA,EACA,MAAA,MACA,aAAA,IAAA,MAAA,eACA,UAAA,kBAGF,eACE,IAAA,EACA,MAAA,EACA,MAAA,MACA,YAAA,IAAA,MAAA,eACA,UAAA,iBAGF,eACE,IAAA,EACA,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,cAAA,IAAA,MAAA,eACA,UAAA,kBAGF,kBACE,MAAA,EACA,KAAA,EACA,OAAA,KACA,WAAA,KACA,WAAA,IAAA,MAAA,eACA,UAAA,iBAGF,gBACE,UAAA,KCjFF,aACE,QAAA,aACA,WAAA,IACA,eAAA,OACA,OAAA,KACA,iBAAA,aACA,QAAA,GAEA,yBACE,QAAA,aACA,QAAA,GAKJ,gBACE,WAAA,KAGF,gBACE,WAAA,KAGF,gBACE,WAAA,MAKA,+BACE,kBAAA,iBAAA,GAAA,YAAA,SAAA,UAAA,iBAAA,GAAA,YAAA,SAIJ,oCACE,IACE,QAAA,IAFJ,4BACE,IACE,QAAA,IAIJ,kBACE,mBAAA,8DAAA,WAAA,8DACA,kBAAA,KAAA,KAAA,UAAA,KAAA,KACA,kBAAA,iBAAA,GAAA,OAAA,SAAA,UAAA,iBAAA,GAAA,OAAA,SAGF,oCACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IAFJ,4BACE,KACE,sBAAA,MAAA,GAAA,cAAA,MAAA,IH9CF,iBACE,QAAA,MACA,MAAA,KACA,QAAA,GIJF,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,gBACE,MAAA,QAGE,sBAAA,sBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QANN,cACE,MAAA,QAGE,oBAAA,oBAEE,MAAA,QANN,aACE,MAAA,QAGE,mBAAA,mBAEE,MAAA,QANN,YACE,MAAA,QAGE,kBAAA,kBAEE,MAAA,QANN,WACE,MAAA,QAGE,iBAAA,iBAEE,MAAA,QCLR,OACE,SAAA,SACA,MAAA,KAEA,eACE,QAAA,MACA,YAAA,uBACA,QAAA,GAGF,SACE,SAAA,SACA,IAAA,EACA,KAAA,EACA,MAAA,KACA,OAAA,KAKF,WACE,kBAAA,KADF,WACE,kBAAA,mBADF,YACE,kBAAA,oBADF,YACE,kBAAA,oBCrBJ,WACE,SAAA,MACA,IAAA,EACA,MAAA,EACA,KAAA,EACA,QAAA,KAGF,cACE,SAAA,MACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,KAQE,YACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,KjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,yBiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,eACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MjDqCF,0BiDxCA,gBACE,SAAA,eAAA,SAAA,OACA,IAAA,EACA,QAAA,MCzBN,QACE,QAAA,KACA,eAAA,IACA,YAAA,OACA,WAAA,QAGF,QACE,QAAA,KACA,KAAA,EAAA,EAAA,KACA,eAAA,OACA,WAAA,QCRF,iB5Dk4MA,0D6D93ME,SAAA,mBACA,MAAA,cACA,OAAA,cACA,QAAA,YACA,OAAA,eACA,SAAA,iBACA,KAAA,wBACA,YAAA,iBACA,OAAA,YCXA,uBACE,SAAA,SACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EACA,QAAA,EACA,QAAA,GCRJ,eCAE,SAAA,OACA,cAAA,SACA,YAAA,OCNF,IACE,QAAA,aACA,WAAA,QACA,MAAA,IACA,WAAA,IACA,iBAAA,aACA,QAAA,ICyDM,gBAOI,eAAA,mBAPJ,WAOI,eAAA,cAPJ,cAOI,eAAA,iBAPJ,cAOI,eAAA,iBAPJ,mBAOI,eAAA,sBAPJ,gBAOI,eAAA,mBAPJ,aAOI,MAAA,eAPJ,WAOI,MAAA,gBAPJ,YAOI,MAAA,eAPJ,WAOI,QAAA,YAPJ,YAOI,QAAA,cAPJ,YAOI,QAAA,aAPJ,YAOI,QAAA,cAPJ,aAOI,QAAA,YAPJ,eAOI,SAAA,eAPJ,iBAOI,SAAA,iBAPJ,kBAOI,SAAA,kBAPJ,iBAOI,SAAA,iBAPJ,UAOI,QAAA,iBAPJ,gBAOI,QAAA,uBAPJ,SAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,SAOI,QAAA,gBAPJ,aAOI,QAAA,oBAPJ,cAOI,QAAA,qBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,QAOI,QAAA,eAPJ,QAOI,WAAA,EAAA,MAAA,KAAA,0BAPJ,WAOI,WAAA,EAAA,QAAA,OAAA,2BAPJ,WAOI,WAAA,EAAA,KAAA,KAAA,2BAPJ,aAOI,WAAA,eAPJ,iBAOI,SAAA,iBAPJ,mBAOI,SAAA,mBAPJ,mBAOI,SAAA,mBAPJ,gBAOI,SAAA,gBAPJ,iBAOI,SAAA,yBAAA,SAAA,iBAPJ,OAOI,IAAA,YAPJ,QAOI,IAAA,cAPJ,SAOI,IAAA,eAPJ,UAOI,OAAA,YAPJ,WAOI,OAAA,cAPJ,YAOI,OAAA,eAPJ,SAOI,KAAA,YAPJ,UAOI,KAAA,cAPJ,WAOI,KAAA,eAPJ,OAOI,MAAA,YAPJ,QAOI,MAAA,cAPJ,SAOI,MAAA,eAPJ,kBAOI,UAAA,+BAPJ,oBAOI,UAAA,2BAPJ,oBAOI,UAAA,2BAPJ,QAOI,OAAA,IAAA,MAAA,kBAPJ,UAOI,OAAA,YAPJ,YAOI,WAAA,IAAA,MAAA,kBAPJ,cAOI,WAAA,YAPJ,YAOI,aAAA,IAAA,MAAA,kBAPJ,cAOI,aAAA,YAPJ,eAOI,cAAA,IAAA,MAAA,kBAPJ,iBAOI,cAAA,YAPJ,cAOI,YAAA,IAAA,MAAA,kBAPJ,gBAOI,YAAA,YAPJ,gBAOI,aAAA,kBAPJ,kBAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,gBAOI,aAAA,kBAPJ,eAOI,aAAA,kBAPJ,cAOI,aAAA,kBAPJ,aAOI,aAAA,kBAPJ,cAOI,aAAA,eAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,UAOI,aAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,MAOI,MAAA,cAPJ,OAOI,MAAA,eAPJ,QAOI,MAAA,eAPJ,QAOI,UAAA,eAPJ,QAOI,MAAA,gBAPJ,YAOI,UAAA,gBAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,MAOI,OAAA,cAPJ,OAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,QAOI,WAAA,eAPJ,QAOI,OAAA,gBAPJ,YAOI,WAAA,gBAPJ,WAOI,KAAA,EAAA,EAAA,eAPJ,UAOI,eAAA,cAPJ,aAOI,eAAA,iBAPJ,kBAOI,eAAA,sBAPJ,qBAOI,eAAA,yBAPJ,aAOI,UAAA,YAPJ,aAOI,UAAA,YAPJ,eAOI,YAAA,YAPJ,eAOI,YAAA,YAPJ,WAOI,UAAA,eAPJ,aAOI,UAAA,iBAPJ,mBAOI,UAAA,uBAPJ,OAOI,IAAA,YAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,gBAPJ,OAOI,IAAA,eAPJ,OAOI,IAAA,iBAPJ,OAOI,IAAA,eAPJ,uBAOI,gBAAA,qBAPJ,qBAOI,gBAAA,mBAPJ,wBAOI,gBAAA,iBAPJ,yBAOI,gBAAA,wBAPJ,wBAOI,gBAAA,uBAPJ,wBAOI,gBAAA,uBAPJ,mBAOI,YAAA,qBAPJ,iBAOI,YAAA,mBAPJ,oBAOI,YAAA,iBAPJ,sBAOI,YAAA,mBAPJ,qBAOI,YAAA,kBAPJ,qBAOI,cAAA,qBAPJ,mBAOI,cAAA,mBAPJ,sBAOI,cAAA,iBAPJ,uBAOI,cAAA,wBAPJ,sBAOI,cAAA,uBAPJ,uBAOI,cAAA,kBAPJ,iBAOI,WAAA,eAPJ,kBAOI,WAAA,qBAPJ,gBAOI,WAAA,mBAPJ,mBAOI,WAAA,iBAPJ,qBAOI,WAAA,mBAPJ,oBAOI,WAAA,kBAPJ,aAOI,MAAA,aAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,KAOI,OAAA,YAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,gBAPJ,KAOI,OAAA,eAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,MAOI,aAAA,YAAA,YAAA,YAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,gBAAA,YAAA,gBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,WAAA,YAAA,cAAA,YAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,gBAAA,cAAA,gBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,YAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,gBAPJ,MAOI,WAAA,eAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,eAPJ,SAOI,WAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,SAOI,aAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,SAOI,cAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,SAOI,YAAA,eAPJ,KAOI,QAAA,YAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,gBAPJ,KAOI,QAAA,eAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,eAPJ,MAOI,cAAA,YAAA,aAAA,YAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,gBAAA,aAAA,gBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,YAAA,YAAA,eAAA,YAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,gBAAA,eAAA,gBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,MAOI,eAAA,YAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,gBAPJ,MAOI,eAAA,eAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,gBAOI,YAAA,mCAPJ,MAOI,UAAA,iCAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,8BAPJ,MAOI,UAAA,gCAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,eAPJ,YAOI,WAAA,iBAPJ,YAOI,WAAA,iBAPJ,UAOI,YAAA,cAPJ,YAOI,YAAA,kBAPJ,WAOI,YAAA,cAPJ,SAOI,YAAA,cAPJ,WAOI,YAAA,iBAPJ,MAOI,YAAA,YAPJ,OAOI,YAAA,eAPJ,SAOI,YAAA,cAPJ,OAOI,YAAA,YAPJ,YAOI,WAAA,eAPJ,UAOI,WAAA,gBAPJ,aAOI,WAAA,iBAPJ,sBAOI,gBAAA,eAPJ,2BAOI,gBAAA,oBAPJ,8BAOI,gBAAA,uBAPJ,gBAOI,eAAA,oBAPJ,gBAOI,eAAA,oBAPJ,iBAOI,eAAA,qBAPJ,WAOI,YAAA,iBAPJ,aAOI,YAAA,iBAPJ,YAOI,UAAA,qBAAA,WAAA,qBAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,gBAIQ,kBAAA,EAGJ,MAAA,+DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,cAIQ,kBAAA,EAGJ,MAAA,6DAPJ,aAIQ,kBAAA,EAGJ,MAAA,4DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,YAIQ,kBAAA,EAGJ,MAAA,2DAPJ,WAIQ,kBAAA,EAGJ,MAAA,0DAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAPJ,eAIQ,kBAAA,EAGJ,MAAA,yBAPJ,eAIQ,kBAAA,EAGJ,MAAA,+BAPJ,YAIQ,kBAAA,EAGJ,MAAA,kBAjBJ,iBACE,kBAAA,KADF,iBACE,kBAAA,IADF,iBACE,kBAAA,KADF,kBACE,kBAAA,EASF,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,cAIQ,gBAAA,EAGJ,iBAAA,6DAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,YAIQ,gBAAA,EAGJ,iBAAA,2DAPJ,WAIQ,gBAAA,EAGJ,iBAAA,0DAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,UAIQ,gBAAA,EAGJ,iBAAA,yDAPJ,SAIQ,gBAAA,EAGJ,iBAAA,wDAPJ,gBAIQ,gBAAA,EAGJ,iBAAA,sBAjBJ,eACE,gBAAA,IADF,eACE,gBAAA,KADF,eACE,gBAAA,IADF,eACE,gBAAA,KADF,gBACE,gBAAA,EASF,aAOI,iBAAA,6BAPJ,iBAOI,oBAAA,cAAA,iBAAA,cAAA,YAAA,cAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,kBAOI,oBAAA,eAAA,iBAAA,eAAA,YAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,iBAPJ,WAOI,cAAA,YAPJ,WAOI,cAAA,gBAPJ,WAOI,cAAA,iBAPJ,WAOI,cAAA,gBAPJ,gBAOI,cAAA,cAPJ,cAOI,cAAA,gBAPJ,aAOI,uBAAA,iBAAA,wBAAA,iBAPJ,aAOI,wBAAA,iBAAA,2BAAA,iBAPJ,gBAOI,2BAAA,iBAAA,0BAAA,iBAPJ,eAOI,0BAAA,iBAAA,uBAAA,iBAPJ,SAOI,WAAA,kBAPJ,WAOI,WAAA,iBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,yByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,gBAOI,MAAA,eAPJ,cAOI,MAAA,gBAPJ,eAOI,MAAA,eAPJ,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,UAOI,IAAA,YAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,gBAPJ,UAOI,IAAA,eAPJ,UAOI,IAAA,iBAPJ,UAOI,IAAA,eAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,eAOI,WAAA,eAPJ,aAOI,WAAA,gBAPJ,gBAOI,WAAA,kBzDPR,0ByDAI,iBAOI,MAAA,eAPJ,eAOI,MAAA,gBAPJ,gBAOI,MAAA,eAPJ,cAOI,QAAA,iBAPJ,oBAOI,QAAA,uBAPJ,aAOI,QAAA,gBAPJ,YAOI,QAAA,eAPJ,aAOI,QAAA,gBAPJ,iBAOI,QAAA,oBAPJ,kBAOI,QAAA,qBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,YAOI,QAAA,eAPJ,eAOI,KAAA,EAAA,EAAA,eAPJ,cAOI,eAAA,cAPJ,iBAOI,eAAA,iBAPJ,sBAOI,eAAA,sBAPJ,yBAOI,eAAA,yBAPJ,iBAOI,UAAA,YAPJ,iBAOI,UAAA,YAPJ,mBAOI,YAAA,YAPJ,mBAOI,YAAA,YAPJ,eAOI,UAAA,eAPJ,iBAOI,UAAA,iBAPJ,uBAOI,UAAA,uBAPJ,WAOI,IAAA,YAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,gBAPJ,WAOI,IAAA,eAPJ,WAOI,IAAA,iBAPJ,WAOI,IAAA,eAPJ,2BAOI,gBAAA,qBAPJ,yBAOI,gBAAA,mBAPJ,4BAOI,gBAAA,iBAPJ,6BAOI,gBAAA,wBAPJ,4BAOI,gBAAA,uBAPJ,4BAOI,gBAAA,uBAPJ,uBAOI,YAAA,qBAPJ,qBAOI,YAAA,mBAPJ,wBAOI,YAAA,iBAPJ,0BAOI,YAAA,mBAPJ,yBAOI,YAAA,kBAPJ,yBAOI,cAAA,qBAPJ,uBAOI,cAAA,mBAPJ,0BAOI,cAAA,iBAPJ,2BAOI,cAAA,wBAPJ,0BAOI,cAAA,uBAPJ,2BAOI,cAAA,kBAPJ,qBAOI,WAAA,eAPJ,sBAOI,WAAA,qBAPJ,oBAOI,WAAA,mBAPJ,uBAOI,WAAA,iBAPJ,yBAOI,WAAA,mBAPJ,wBAOI,WAAA,kBAPJ,iBAOI,MAAA,aAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,gBAOI,MAAA,YAPJ,SAOI,OAAA,YAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,gBAPJ,SAOI,OAAA,eAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,eAPJ,YAOI,OAAA,eAPJ,UAOI,aAAA,YAAA,YAAA,YAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,gBAAA,YAAA,gBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,aAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,WAAA,YAAA,cAAA,YAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,gBAAA,cAAA,gBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,aAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,YAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,gBAPJ,UAOI,WAAA,eAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,eAPJ,aAOI,WAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,aAOI,aAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,aAOI,cAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,aAOI,YAAA,eAPJ,SAOI,QAAA,YAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,gBAPJ,SAOI,QAAA,eAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,eAPJ,UAOI,cAAA,YAAA,aAAA,YAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,gBAAA,aAAA,gBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,YAAA,YAAA,eAAA,YAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,gBAAA,eAAA,gBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,UAOI,eAAA,YAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,gBAPJ,UAOI,eAAA,eAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,gBAOI,WAAA,eAPJ,cAOI,WAAA,gBAPJ,iBAOI,WAAA,kBCnDZ,0BD4CQ,MAOI,UAAA,iBAPJ,MAOI,UAAA,eAPJ,MAOI,UAAA,kBAPJ,MAOI,UAAA,kBChCZ,aDyBQ,gBAOI,QAAA,iBAPJ,sBAOI,QAAA,uBAPJ,eAOI,QAAA,gBAPJ,cAOI,QAAA,eAPJ,eAOI,QAAA,gBAPJ,mBAOI,QAAA,oBAPJ,oBAOI,QAAA,qBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,cAOI,QAAA","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}-root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`